功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。
## 新增能力
1) `hdiv backtest --mode daily --start <日期>`
- src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
- 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
- `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
- `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
(卖出/减仓同样留痕,否则「为什么卖」缺证据)
- 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
因此不使用训练段、不冻结分布,阈值口径一律 rolling
- 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)
2) PIT 批量取数层 src/hdiv/universe/pit.py
- PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
—— 以保证与逐日单点查询**结果等价**
- 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
- src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)
3) 每日增量同步 `hdiv sync daily`
- src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
支持 `--dry-run` 先看待抓清单
- deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
- 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
(该表已存在于库中,`ddl plan` 返回 0 个待执行动作)
4) Web 与文档
- 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
web/favicon.svg)
- README / docs/user-guide.md / docs/implementation-status.md 同步更新:
三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段
## 测试
tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。
已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**
未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
`SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
(31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
区间 2024-03-01~03-15),说明该路径可正常完成。
## 已知待改进
- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
"""每日增量同步(``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 记录"
|
||||
Reference in New Issue
Block a user