Files
qlib/backend/app/quant/qlib_adapter/engine.py
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

99 lines
4.1 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.
"""QlibEngine —— 基于 Qlib 数据管线的研究引擎(QuantEngine 实现)。
v1 能力(本阶段已打通并测试):
1. provider.build_qlib_dataset:把本地行情按 qlib 二进制格式落盘(data/qlib)
2. dataset.ensure_qlib_init + D.features:从 QlibDataset 读取行情(真实 qlib 通路)
3. 在 Qlib 读取的行情面板上执行 TopK 因子回测 → 标准 BacktestResult
(与 LocalEngine 记账规则一致:无未来函数、成本/涨跌停/停牌近似 + unimplemented 标注)
说明:
- 因子分(score)在源行情上按 app.quant.factors 计算(注册表口径一致);
qlib 侧负责数据读取供给(未来可由 Qlib Dataset 直接产出特征)。
- Alpha158 特征集 + LightGBM 预测信号的模型增强(walk-forward 训练/预测)为下一步
TODO,详见 docs/ROADMAP.md §2 与 docs/QLIB_VERIFICATION.md。
- 默认研究引擎仍是 LocalEngine(app/quant/engine.py);切换只需注入本类。
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.engine import QuantEngine
from app.quant.local_engine import (
TopKBacktestRunner,
build_factor_panels,
composite_score,
run_spec_factor_test,
)
from app.quant.qlib_adapter.dataset import ensure_qlib_init, load_close_panel
from app.quant.qlib_adapter.provider import build_qlib_dataset
_ENGINE_NOTE = "QlibEngine v1:本地行情→QlibDataset(bin)→D.features 读取→TopK 因子回测"
class QlibEngine(QuantEngine):
"""基于 Qlib 数据管线的引擎;因子评估复用共享实现,回测从 QlibDataset 读取行情。"""
name = "qlib"
# qlib 落盘需要 OHLCV(vwap/factor 由本地合成,不来自行情表)
_DUMP_COLUMNS = {"open", "high", "low", "close", "volume", "amount"}
def __init__(self, qlib_dir: Path | None = None) -> None:
# 默认落盘到 data/qlib(与 storage.qlib_dir 一致);可注入临时目录便于测试
self.qlib_dir = qlib_dir or _default_qlib_dir()
def required_columns(self, spec: ResearchSpec) -> set[str]:
# 回测需把全字段落盘成 qlib 数据集;因子测试走共享实现,只需 close+因子字段
if spec.type == "backtest":
return set(self._DUMP_COLUMNS)
from app.quant.engine import factor_required_columns
return factor_required_columns(spec)
def run_factor_test(
self, daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21
) -> FactorTestReport:
# 因子评估与数据无关,直接复用共享实现(与 LocalEngine 同口径)
report, _panels = run_spec_factor_test(daily, spec, horizon_days)
return report
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
"""回测(与 LocalEngine 共用 TopKBacktestRunner → 口径一致)。
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
"""
panels = build_factor_panels(daily, spec.factors)
score = composite_score(panels)
self.qlib_dir.mkdir(parents=True, exist_ok=True)
uri = build_qlib_dataset(daily, self.qlib_dir)
ensure_qlib_init(uri)
symbols = [str(s) for s in daily["symbol"].unique()]
start, end = spec.period
close = load_close_panel(uri, symbols, start, end)
if close.empty:
raise RuntimeError(
"QlibDataset 读取为空:请检查 build_qlib_dataset 落盘与 provider_uri"
)
close = close.sort_index()
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
result.config_snapshot = spec.model_dump(mode="json")
note = _ENGINE_NOTE
result.unimplemented = [note, *result.unimplemented]
return result
def _default_qlib_dir() -> Path:
from app.core.config import PROJECT_ROOT
return PROJECT_ROOT / "data" / "qlib"