Files
sanguo_vnpy_v2/tests/data/test_backfill_15min_hardening.py
T
claude_dev c041227139 chore: gitignore放行tests/data+docs/data(老坑data/误伤),补回硬化测试
data/ 规则匹配任意 data 目录,误伤 tests/data(硬化测试)和 docs/data(15min设计文档),
此前靠 git add -f 临时绕。加 !tests/data/ !docs/data/ 例外一劳永逸。
2026-07-13 11:59:39 +08:00

235 lines
8.6 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.
"""
硬化测试:backfill_15min_baostock.py 三个硬伤的回归覆盖。
覆盖:
1. 异常时强制重登(_relogin 在 backfill_one 重试 loop 中被调用)
2. 断路器(连续 N 失败 → break + sys.exit(2)
3. marker-based resumeload_progress 从 marker 文件构造 done_set
4. _login_with_timeout SIGALRM 超时(防 bs.login 永久挂起)
mock baostock,不发起真实连接。
"""
import os
import sys
import tempfile
import time
from pathlib import Path
import pandas as pd
import pytest
# 在 import backfill 模块前重定向 STOCK_ROOT,避免 setup_logging() 写入 NAS 挂载点
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
_FAKE_ROOT = tempfile.mkdtemp(prefix="backfill_test_")
os.environ["STOCK_ROOT"] = _FAKE_ROOT
import scripts.data_platform.backfill_15min_baostock as bf # noqa: E402
# ======================== fixture ========================
@pytest.fixture
def fake_df():
"""一份合法的 15min DataFrame,够 backfill_one 写盘"""
return pd.DataFrame({
"day": ["2024-01-02 09:45:00", "2024-01-02 10:00:00"],
"open": [10.0, 10.2], "high": [10.3, 10.4],
"low": [9.9, 10.1], "close": [10.2, 10.3],
"volume": ["1000", "1200"], "amount": ["10000", "12000"],
})
# ======================== 1. 异常时强制重登 ========================
def test_relogin_called_on_exception(tmp_path, monkeypatch, fake_df):
"""
fetch_bs_15min 第一次抛 socket 异常 →
backfill_one 重试 loop 应调 _relogin() 强制重登(新 socket)→
第二次 fetch 成功 → status=ok。
"""
calls = {"fetch": 0, "relogin": 0}
def mock_fetch(bs_code, start_date, end_date):
calls["fetch"] += 1
if calls["fetch"] == 1:
raise OSError(57, "Socket is not connected") # 模拟 baostock 断连
return fake_df
def mock_relogin():
calls["relogin"] += 1
return True
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path)
monkeypatch.setattr(bf, "fetch_bs_15min", mock_fetch)
monkeypatch.setattr(bf, "_relogin", mock_relogin)
monkeypatch.setattr(bf, "is_backfilled", lambda p: False)
status, rows = bf.backfill_one("000001", "2024-01-01", "2026-01-01", force=True)
assert status == "ok"
assert rows == 2
assert calls["fetch"] == 2 # 重试了一次
assert calls["relogin"] == 1 # 异常分支强制重登一次
def test_relogin_failure_aborts_retry(tmp_path, monkeypatch):
"""
异常 + _relogin 返回 False → 直接放弃,不再空转重试。
"""
fetch_calls = {"n": 0}
def mock_fetch(*args, **kwargs):
fetch_calls["n"] += 1
raise OSError(57, "Socket is not connected")
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path)
monkeypatch.setattr(bf, "fetch_bs_15min", mock_fetch)
monkeypatch.setattr(bf, "_relogin", lambda: False)
monkeypatch.setattr(bf, "is_backfilled", lambda p: False)
status, rows = bf.backfill_one("000001", "2024-01-01", "2026-01-01", force=True)
assert status == "failed"
# _relogin 失败立即 breakfetch 只被调用 1 次(不会重试到 BS_MAX_RETRIES
assert fetch_calls["n"] == 1
# ======================== 2. 断路器 ========================
def test_circuit_breaker_triggers(monkeypatch, tmp_path):
"""
主循环连续 N 只 failed → 断路器触发,sys.exit(2)。
不真实登录 baostock、不读 CSV。
"""
# 缩小阈值,加快测试
monkeypatch.setattr(bf, "CIRCUIT_BREAKER_THRESHOLD", 3)
# 所有桩
monkeypatch.setattr(bf, "NAS_ROOT", tmp_path) # NAS_ROOT.exists() 通过
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path / "15min")
monkeypatch.setattr(bf, "_login_with_timeout", lambda timeout=30: True)
monkeypatch.setattr(bf, "_relogin", lambda: True)
monkeypatch.setattr(bf, "bs", type("S", (), {
"logout": staticmethod(lambda: None),
"login": staticmethod(lambda: type("L", (), {"error_code": "0", "error_msg": ""})()),
}))
monkeypatch.setattr(bf, "backfill_one", lambda *a, **k: ("failed", 0))
monkeypatch.setattr(bf, "save_progress", lambda s: None)
monkeypatch.setattr(bf, "time", type("T", (), {
"sleep": staticmethod(lambda s: None),
"time": staticmethod(time.time),
}))
# argv5 个 fake 代码,触发阈值 3 后应中途 break
monkeypatch.setattr(sys, "argv", [
"backfill_15min_baostock.py",
"--codes", "000001,000002,000003,000004,000005",
])
with pytest.raises(SystemExit) as exc_info:
bf.main()
assert exc_info.value.code == 2, "断路器触发应以退出码 2 退出"
def test_circuit_breaker_resets_on_ok(monkeypatch, tmp_path):
"""
ok 重置连续失败计数:N-1 失败后 1 个 ok,再 N-1 失败 → 不应触发断路。
最终正常退出(无 SystemExit)。
"""
monkeypatch.setattr(bf, "CIRCUIT_BREAKER_THRESHOLD", 3)
monkeypatch.setattr(bf, "NAS_ROOT", tmp_path)
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path / "15min")
monkeypatch.setattr(bf, "_login_with_timeout", lambda timeout=30: True)
monkeypatch.setattr(bf, "_relogin", lambda: True)
monkeypatch.setattr(bf, "bs", type("S", (), {
"logout": staticmethod(lambda: None),
"login": staticmethod(lambda: type("L", (), {"error_code": "0", "error_msg": ""})()),
}))
# 序列:fail, fail, ok, fail, fail → 连续计数峰 2 < 3,不触发
seq = [("failed", 0), ("failed", 0), ("ok", 1), ("failed", 0), ("failed", 0)]
monkeypatch.setattr(bf, "backfill_one", lambda *a, **k: seq.pop(0))
monkeypatch.setattr(bf, "save_progress", lambda s: None)
monkeypatch.setattr(bf, "time", type("T", (), {
"sleep": staticmethod(lambda s: None),
"time": staticmethod(time.time),
}))
monkeypatch.setattr(sys, "argv", [
"backfill_15min_baostock.py",
"--codes", "000001,000002,000003,000004,000005",
])
# 正常跑完不应抛 SystemExit
bf.main()
# ======================== 3. marker-based resume ========================
def test_load_progress_from_markers(tmp_path, monkeypatch):
"""
load_progress 从 .{prefix}{code}_15min.baostock marker 构造 done_set。
failed 票无 marker → 不在 done_set → 下次复跑会被重试。
"""
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path)
# 成功票:写 marker
for name in [".sh600000_15min.baostock", ".sz000001_15min.baostock",
".sh688981_15min.baostock"]:
(tmp_path / name).write_text("2026-07-13T00:00:00")
# 失败票:只有 parquet,无 marker(不应进 done_set
(tmp_path / "sz300750_15min.parquet").write_text("dummy")
# 无关文件
(tmp_path / ". unrelated.baostock").write_text("x")
(tmp_path / ".sh600000_daily.baostock").write_text("x") # 非 _15min
done = bf.load_progress()
assert done == {"600000", "000001", "688981"}
assert "300750" not in done # failed(无 marker)会被重试
def test_load_progress_empty_when_dir_missing(tmp_path, monkeypatch):
"""目录不存在 → 空集合(不抛异常)"""
monkeypatch.setattr(bf, "MINUTE_15_DIR", tmp_path / "nonexistent")
assert bf.load_progress() == set()
# ======================== 4. login 超时 ========================
def test_login_with_timeout_fires(monkeypatch):
"""
bs.login() 卡死(sleep 远大于 timeout)→ SIGALRM 触发 → 返回 False。
验证不会永久挂起。
"""
def slow_login():
time.sleep(10) # 模拟 baostock 卡死
return type("L", (), {"error_code": "0", "error_msg": ""})()
monkeypatch.setattr(bf, "bs", type("S", (), {"login": staticmethod(slow_login)}))
t0 = time.time()
ok = bf._login_with_timeout(timeout=1)
elapsed = time.time() - t0
assert ok is False
assert elapsed < 3, f"超时应快速返回,实际 {elapsed:.1f}s"
def test_login_with_timeout_success(monkeypatch):
"""正常登录 → 返回 True"""
def quick_login():
return type("L", (), {"error_code": "0", "error_msg": ""})()
monkeypatch.setattr(bf, "bs", type("S", (), {"login": staticmethod(quick_login)}))
assert bf._login_with_timeout(timeout=5) is True
def test_login_with_timeout_login_failure(monkeypatch):
"""baostock 返回 error_code != 0 → 返回 False"""
def fail_login():
return type("L", (), {"error_code": "10001", "error_msg": "网络断开"})()
monkeypatch.setattr(bf, "bs", type("S", (), {"login": staticmethod(fail_login)}))
assert bf._login_with_timeout(timeout=5) is False