Files
qlib/backend/tests/test_quant_engine.py
T
Simon e9f59d3cf8 feat(backend): Phase 2 研究引擎 — ResearchSpec / 因子 / 评估 / 低频回测 / 引擎抽象
- domain:ResearchSpec(universe/factors/selection/rebalance/costs 校验)+ 标准化 BacktestResult / FactorTestReport
- 因子引擎:注册表 + 元数据,内置 9 个行情因子(momentum/volatility/量比/乖离/反转),支持自定义注册;只用行情字段规避未来函数
- 评估:横截面 IC / RankIC(rank+pearson 免 scipy)/ ICIR / 分层收益
- 回测:TopK 等权低频,无未来函数记账(t 收盘成交、自 t+1 计收益),成本/涨跌停/停牌约束,未建模项显式写入 unimplemented(AGENT §24)
- 引擎抽象 QuantEngine + LocalEngine(pandas 默认实现);qlib_adapter 桥接占位 —— pyqlib 无 aarch64+cp312 wheel(ROADMAP 已备注)
- 真实链路冒烟:600519 2024 月度动量回测闭环产出标准结果
- 测试 60 passed / ruff clean
2026-09-06 17:08:00 +08:00

139 lines
5.2 KiB
Python

"""LocalEngine 回测测试:主路径、成本、涨跌停/不可买约束、无未来函数构造。"""
from __future__ import annotations
from datetime import date
import pandas as pd
from app.domain.entities.market import Stock
from app.domain.entities.research import (
CostSpec,
FactorSpec,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.quant.engine import LocalEngine
from app.quant.service import filter_stocks
from conftest_quant import synthetic_daily
def _spec(
top_n: int = 1,
start: date = date(2024, 3, 1),
end: date = date(2024, 10, 31),
rebalance: str = "monthly",
costs: CostSpec | None = None,
) -> ResearchSpec:
return ResearchSpec(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=[FactorSpec(name="momentum_20")],
selection=SelectionSpec(top_n=top_n),
rebalance=rebalance,
period=(start, end),
costs=costs or CostSpec(),
)
class TestBacktestMain:
def test_uptrend_wins_and_profits(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": -0.002}, n=320)
res = LocalEngine().run_backtest(daily, _spec(top_n=1))
assert res.summary.total_return_pct > 0
assert res.summary.final_equity > res.summary.initial_capital
assert res.summary.total_trades >= 1
assert res.summary.annual_return_pct > 0
assert res.equity_curve[0].date == date(2024, 3, 1)
assert res.equity_curve[-1].date == date(2024, 10, 31)
assert res.monthly_returns
assert res.trades
# AGENT §24:未建模约束必须显式标注
assert any("涨跌停" in item for item in res.unimplemented)
assert res.config_snapshot["selection"]["top_n"] == 1
def test_costs_reduce_returns(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": -0.002}, n=320)
free = CostSpec(commission_rate=0.0, stamp_tax_rate=0.0, slippage_rate=0.0)
with_cost = LocalEngine().run_backtest(daily, _spec(top_n=2, costs=CostSpec()))
without = LocalEngine().run_backtest(daily, _spec(top_n=2, costs=free))
# 有成本时收益不应高于无成本
assert with_cost.summary.total_return_pct <= without.summary.total_return_pct + 1e-6
def test_positions_and_drawdown_wellformed(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": -0.001}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=2))
assert res.positions
weights = [p.weight for p in res.positions]
assert all(0 < w <= 1 for w in weights)
assert all(p.value <= 0 for p in res.drawdown)
assert res.summary.max_drawdown_pct <= 0
def _limit_up_scenario_daily() -> pd.DataFrame:
"""Y 在 2024-07-01 相对前一交易日跳涨 10.5%(主板涨停不可追),且动量高于 X。"""
dates = pd.bdate_range("2024-06-03", periods=46)
rows = []
for j, d in enumerate(dates):
x = 100.0 * 1.001**j
y = 100.0 * 1.003**j
# 2024-07-01 是第 21 个工作日(6/28 周五 → 7/1 周一)
if d.date() == date(2024, 7, 1):
y = y / 1.003 * 1.105 # 相对前一日 +10.5%,形成涨停
rows.append(
{
"symbol": "600001.SH",
"trade_date": d.date(),
"close": x,
"open": x,
"high": x * 1.01,
"low": x * 0.99,
"volume": 1e6,
"amount": 1e8,
}
)
rows.append(
{
"symbol": "600002.SH",
"trade_date": d.date(),
"close": y,
"open": y,
"high": y * 1.01,
"low": y * 0.99,
"volume": 1e6,
"amount": 1e8,
}
)
return pd.DataFrame(rows)
class TestLimitUpConstraint:
def test_limit_up_symbol_not_bought(self) -> None:
daily = _limit_up_scenario_daily()
res = LocalEngine().run_backtest(
daily, _spec(top_n=1, start=date(2024, 6, 3), end=date(2024, 8, 2))
)
# 7/1 调仓:Y 动量更高但涨停不可买 → 当日只能买入 X(8 月 Y 恢复可买,不断言之后)
at_0701 = [p for p in res.positions if p.date == date(2024, 7, 1)]
assert at_0701, "7/1 调仓后应记录持仓"
assert {p.symbol for p in at_0701} == {"600001.SH"}
class TestServiceFilter:
def _stock(self, symbol: str, name: str, list_date: date, delist: date | None = None) -> Stock:
return Stock(symbol=symbol, name=name, list_date=list_date, delist_date=delist)
def test_exclude_st_and_new_and_delisted(self) -> None:
stocks = [
self._stock("600001.SH", "*ST 某某", date(2000, 1, 1)),
self._stock("600002.SH", "正常公司", date(2024, 6, 1)), # 上市不足 250 天
self._stock("600003.SH", "正常公司", date(2010, 1, 1), delist=date(2023, 6, 1)),
self._stock("600004.SH", "正常公司", date(2010, 1, 1)),
]
kept = filter_stocks(
stocks, UniverseSpec(exclude_st=True, min_listing_days=250), as_of=date(2024, 8, 1)
)
assert [s.symbol for s in kept] == ["600004.SH"]