docs(data): Phase2 归档旧回填/import链+15m灌库链(VPS schtask实证线头死)
VPS schtasks /query: 调用这些脚本的 schtask 全已禁用(baostock_day1/2, xtdata-build, index-hist, bs-daily-increment 等),且不被8个活跃wrapper引用。 活跃白名单(保留): bs_eod/xt_eod/sina_index_eod/akshare_static_download/ index_const_hist_download/merge_constituent/migrate_constituent/parse_csindex_announce - _archive/backfill_legacy/: 31个(旧回填import链+旧baostock下载+禁用wrapper+Mac .sh链) - _archive/backfill_15m/: 7个(15min灌库链+hardening测试,backfill已完成) - 关键实证: index_const_hist_download 被 index_monthly_wrapper(活跃)调用→保留不归档 - 无残留import(fetch_with_fallback是sanguo_data函数非fallback模块)
This commit is contained in:
@@ -16,8 +16,31 @@
|
||||
|
||||
归档前已验证:**零 import、无活跃 wrapper 引用**。
|
||||
|
||||
## backfill_15m/ — 15min 一次性灌库链(Phase 2 待归档)
|
||||
## backfill_legacy/ — 旧回填/import 链 + 禁用 wrapper + Mac .sh 链(2026-07-29 归档)
|
||||
|
||||
⚠️ 未归档。`backfill_15min_baostock` 被 `tests/data/test_backfill_15min_hardening.py` 正式 import,
|
||||
`refresh_15min_daily` / `download_minute` / `download_15m_xtdata` / `raw_redownload` / `audit_data_layout`
|
||||
存在交叉引用或 ops wrapper 依赖,需 Phase 2 评估后统一处理。
|
||||
31 个文件。方案 A 后由 bs_eod/xt_eod 接管的旧路径。**VPS schtasks /query 实证**:调用这些脚本的
|
||||
schtask(baostock_day1/day2、xtdata-build、index-hist、bs-daily-increment、bs-akshare、bs-static、
|
||||
bs15min、daily-update 等)**全部已禁用**,且不被 8 个活跃 wrapper 引用。
|
||||
|
||||
- 旧回填/import:`build_daily_from_xtdata` / `import_vnpy_*` / `import_baostock_to_db` / `merge_daily_baostock` / `migrate_daily_baostock` / `raw_redownload` / `daily_all_update`
|
||||
- 旧 baostock 下载:`baostock_download` / `baostock_constituent_download` / `baostock_static_download` / `baostock_delisted_download` / `baostock_daily_fullmarket_download`
|
||||
- 禁用 wrapper:`baostock_day1/day2a/day2b_wrapper` / `import_bs_wrapper` / `daily_update_static_wrapper` / `index_const_hist_wrapper` / `_run_import` / `_run_build` / `_run_daily` / `relaunch_15m_wrapper`
|
||||
- Mac 旧 .sh 链:`daily_update.sh` / `daily_update_all.sh` / `run_daily_update.sh` / `full_deploy_5yr.sh` / `_launch_15m.bat`
|
||||
|
||||
## backfill_15m/ — 15min 一次性灌库链 + 测试(2026-07-29 归档)
|
||||
|
||||
7 个文件。15min 全市场灌库已于 2026-07-20 完成,方案 A 后由 `sanguo-bs-eod` 每日增量接管。
|
||||
`backfill_15min_baostock` + `baostock_15min_fullmarket_download` + `baostock_15min_sample_to_db` +
|
||||
`refresh_15min_daily` + `download_minute` + `download_15m_xtdata` + `test_backfill_15min_hardening`
|
||||
(hardening 测试随被测脚本一并归档,backfill 不再执行)。
|
||||
|
||||
## ⚠️ 归档时关键实证
|
||||
|
||||
`index_const_hist_download.py` 被 `index_monthly_wrapper.ps1`(sanguo-index 月度,**活跃**)调用
|
||||
→ **保留在顶层,不归档**。归档前须查活跃 wrapper 调用集,勿凭"一次性脚本"直觉判断。
|
||||
|
||||
## 保留在顶层(活跃生产,不归档)
|
||||
|
||||
`bs_eod` / `xt_eod` / `sina_index_eod` / `akshare_static_download` / `index_const_hist_download` /
|
||||
`merge_constituent` / `migrate_constituent` / `parse_csindex_announce` / `validator` / `validate_import` /
|
||||
`verify_*` / `cleanup_staging` / `backup_db` / `dbbardata_utils` / 活跃 wrapper / `register_schtasks` / ops 工具
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
硬化测试:backfill_15min_baostock.py 三个硬伤的回归覆盖。
|
||||
|
||||
覆盖:
|
||||
1. 异常时强制重登(_relogin 在 backfill_one 重试 loop 中被调用)
|
||||
2. 断路器(连续 N 失败 → break + sys.exit(2))
|
||||
3. marker-based resume(load_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 失败立即 break:fetch 只被调用 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),
|
||||
}))
|
||||
# argv:5 个 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
|
||||
Reference in New Issue
Block a user