- 因子元数据增加 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
150 lines
5.9 KiB
Python
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"]
|