Files
qlib/backend/tests/test_quant_engine.py
Simon a77d3c13c3 feat(web): 因子研究升级 + 因子组合页
- 因子元数据增加 brief 使用简介(后端 FactorDef + /api/factors 返回,9 个内置因子补齐文案)
- 因子研究页:
  · 时间默认「今日 ~ 近 6 个月」
  · 因子多选,逐个跑 IC/RankIC Job 并分卡展示报告
  · 目录显示使用简介,行点击展开公式/方向/用法详解
  · 新增计分规则说明与「去因子组合」入口
- 因子组合页 /factors/compose:多因子勾选+权重、组合得分规则说明(z-score×权重求和→TopN),
  可配 topN/调仓/区间/ST,一键回测 Job → 净值/回撤/月度/未建模标注
- 回测页时间默认同样近 6 月
- 验证:tsc + next build 通过;组合回测(momentum_60×1.5 + volatility_60×1.0)Job 成功归档
  EXP-330CAAAE(-13.51%,交易 20);后端 factors/research/api 相关测试通过 / ruff clean
2026-09-07 21:36:16 +08:00

150 lines
5.9 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 test_required_columns_follow_factor_dependencies(self) -> None:
"""数据装配按引擎所需列裁剪:momentum 只取 close;量比因子需 volume。"""
engine = LocalEngine()
momentum = _spec() # momentum_20
assert engine.required_columns(momentum) == {"close"}
volume = _spec().model_copy(update={"factors": [FactorSpec(name="volume_ratio_5_60")]})
assert engine.required_columns(volume) == {"close", "volume"}
unknown = _spec().model_copy(update={"factors": [FactorSpec(name="no_such")]})
# 未知因子不参与列裁剪,交由执行期统一报错
assert engine.required_columns(unknown) == {"close"}
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"]