Files
qlib/backend/tests/test_qlib_engine.py
Simon b8f67f99ae feat(quant): QlibEngine v1 — 本地行情落盘 QlibDataset → D.features 读取 → 因子回测
- qlib_adapter/provider.py:SQLite 行情按 qlib 0.9.8 二进制格式落盘(起始索引头 + 逐日 float32、instruments 3 列、小写 instrument、晚上市 offset)
- qlib_adapter/dataset.py:qlib.init 幂等({'day': uri})+ D.features 读取 close 面板
- qlib_adapter/engine.py:QlibEngine(QuantEngine)v1 —— Qlib 数据管线回测与 LocalEngine 同记账规则(无未来函数/成本/涨跌停标注),factor_test 复用共享实现;Alpha158+LightGBM 为 TODO
- 真实 20 股验证:qlib 落盘 142 文件→读取→回测(-12.81%,Local 对照 -12.97%,差异为 qlib float32 存储)
- tests/test_qlib_engine.py 5 项(格式/roundtrip/晚上市 offset/回测/因子测试)→ pytest 86 passed / ruff clean
2026-09-06 18:20:28 +08:00

110 lines
4.3 KiB
Python

"""QlibEngine 数据管线测试:落盘格式、D.features 读回、端到端回测(合成数据、临时 qlib 目录)。"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from app.domain.entities.research import ResearchSpec, UniverseSpec
from app.quant.qlib_adapter.dataset import load_close_panel
from app.quant.qlib_adapter.engine import QlibEngine
from app.quant.qlib_adapter.provider import build_qlib_dataset
from conftest_quant import synthetic_daily
def _daily5(n: int = 220) -> pd.DataFrame:
drifts = {
"600519.SH": 0.004,
"600036.SH": 0.002,
"601318.SH": 0.001,
"000001.SZ": -0.001,
"600030.SH": -0.003,
}
return synthetic_daily(drifts, n=n)
def _spec(**kw) -> ResearchSpec:
base = dict(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 2},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 8, 31)),
)
base.update(kw)
return ResearchSpec.model_validate(base)
class TestProviderFormat:
def test_bin_has_start_index_header(self, tmp_path: Path) -> None:
daily = synthetic_daily({"600519.SH": 0.003, "600036.SH": -0.001}, n=200)
uri = build_qlib_dataset(daily, tmp_path)
cal = (uri / "calendars" / "day.txt").read_text().strip().splitlines()
assert len(cal) == 200
inst_line = (uri / "instruments" / "all.txt").read_text().strip().splitlines()[0]
assert inst_line.count("\t") == 2 # 3 列,无 TYPE 列
# 落盘目录用小写 instrument;bin 首 4 字节 = 起始日历下标
raw = (uri / "features" / "600519.sh" / "close.day.bin").read_bytes()
assert np.frombuffer(raw[:4], dtype="<f")[0] == pytest.approx(0.0)
assert len(raw) // 4 - 1 == 200 # 数据长度 = 日历交易日数
def test_bin_with_late_listing(self, tmp_path: Path) -> None:
daily = synthetic_daily({"600000.SH": 0.002, "600001.SH": 0.001}, n=120)
days = daily["trade_date"].unique()
merged = pd.concat(
[
daily[daily["symbol"] == "600000.SH"],
daily[(daily["symbol"] == "600001.SH") & (daily["trade_date"] >= days[50])],
]
)
uri = build_qlib_dataset(merged, tmp_path)
raw = (uri / "features" / "600001.sh" / "close.day.bin").read_bytes()
assert np.frombuffer(raw[:4], dtype="<f")[0] == pytest.approx(50.0)
class TestQlibDatasetRead:
def test_load_close_panel_roundtrip(self, tmp_path: Path) -> None:
daily = synthetic_daily({"600519.SH": 0.003, "600036.SH": -0.001}, n=120)
uri = build_qlib_dataset(daily, tmp_path)
panel = load_close_panel(
uri, ["600519.SH", "600036.SH"], date(2024, 2, 1), date(2024, 5, 31)
)
assert len(panel) > 0
assert set(panel.columns) == {"600519.SH", "600036.SH"}
src = daily[daily["symbol"] == "600519.SH"].set_index("trade_date")["close"]
src.index = pd.to_datetime(src.index)
target = panel["600519.SH"]
overlap = src.index.intersection(target.index)
assert len(overlap) > 10
rel = (target.loc[overlap] / src.loc[overlap].astype(float) - 1).abs().max()
assert rel < 1e-3 # qlib 存储为 float32,允许微小舍入
class TestQlibEngine:
def test_backtest_via_qlib_pipeline(self, tmp_path: Path) -> None:
daily = _daily5()
result = QlibEngine(qlib_dir=tmp_path).run_backtest(daily, _spec())
assert result.summary.total_return_pct > 0 # 强趋势下动量择股盈利
assert len(result.equity_curve) > 50
assert any("QlibEngine v1" in u for u in result.unimplemented)
assert result.config_snapshot["factors"][0]["name"] == "momentum_60"
assert (tmp_path / "features").is_dir() # 数据确实落盘到 qlib 目录
def test_factor_test_shared_path(self, tmp_path: Path) -> None:
daily = _daily5()
spec = _spec()
spec.type = "factor_test"
report = QlibEngine(qlib_dir=tmp_path).run_factor_test(daily, spec)
assert report.factor_name == "momentum_60"
assert report.sample_days > 5
assert report.ic_mean > 0 # 5 只强趋势股票的截面动量 IC 为正