- 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
139 lines
5.2 KiB
Python
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"]
|