Files
qlib/backend/tests/test_interval_selection.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

329 lines
16 KiB
Python

"""择股/调仓双周期(m/y)、两级截断(n/x)与顺延买入(defer_buy)测试。
对应用户案例:「全市场股息率最高的 n 只 → 持仓前 x 只;每 m 个月择股一次;
每 y 个月调仓,默认 y=m;买卖点为收盘价;买不进时顺延到之后不涨停的交易日买入」。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from app.domain.entities.research import (
CostSpec,
FactorSpec,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.quant.engine import LocalEngine
from app.quant.local_engine import rebalance_dates
from pydantic import ValidationError
from conftest_quant import synthetic_daily
def _spec(
top_n: int = 2,
hold_top_x: int | None = None,
start: date = date(2024, 3, 1),
end: date = date(2024, 12, 20),
m: int | None = None,
y: int | None = None,
allow_substitute: bool = False,
defer_buy: bool = True,
) -> ResearchSpec:
"""合成行情自 2024-01-01 起(因子预热),回测自 2024-03-01 起(首个调仓日即有信号)。"""
return ResearchSpec(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=[FactorSpec(name="momentum_20")],
selection=SelectionSpec(
top_n=top_n,
hold_top_x=hold_top_x,
allow_substitute=allow_substitute,
defer_buy=defer_buy,
),
rebalance="monthly",
selection_interval_months=m,
rebalance_interval_months=y,
period=(start, end),
costs=CostSpec(commission_rate=0.0, stamp_tax_rate=0.0, slippage_rate=0.0),
)
class TestIntervalSchedule:
def test_every_n_months_anchored_at_start_month(self) -> None:
idx = pd.bdate_range("2020-01-01", "2021-12-31")
out = rebalance_dates(idx, "monthly", date(2020, 1, 1), every_months=6)
assert [d.strftime("%Y-%m-%d") for d in out] == [
"2020-01-01", "2020-07-01", "2021-01-01", "2021-07-01",
]
def test_anchor_moves_with_start_month(self) -> None:
idx = pd.bdate_range("2020-01-01", "2021-12-31")
out = rebalance_dates(idx, "monthly", date(2020, 3, 1), every_months=6)
assert [d.strftime("%Y-%m-%d") for d in out] == [
"2020-03-02", "2020-09-01", "2021-03-01", "2021-09-01",
]
def test_start_not_first_trading_day_keeps_anchor_month(self) -> None:
"""起始日非月初时不得跳过锚点月(否则白等 m 个月才首次建仓)。"""
idx = pd.bdate_range("2024-01-01", "2025-12-31")
out = rebalance_dates(idx, "monthly", date(2024, 3, 15), every_months=6)
# 2024-03-15 本身是交易日 → 锚点即当日;后续按 +6 个月推进
assert [d.strftime("%Y-%m-%d") for d in out] == [
"2024-03-15", "2024-09-02", "2025-03-03", "2025-09-01",
]
# 起始日落在非交易日(2024-03-16/17 为周末)→ 取之后首个交易日,仍属 3 月
out2 = rebalance_dates(idx, "monthly", date(2024, 3, 16), every_months=6)
assert out2[0].strftime("%Y-%m-%d") == "2024-03-18"
def test_start_after_last_trading_day_of_month_moves_anchor(self) -> None:
"""起始日晚于该月最后一个交易日时,锚点自然落到下一个月(不产生空区间)。"""
idx = pd.bdate_range("2024-01-01", "2025-12-31")
out = rebalance_dates(idx, "monthly", date(2024, 3, 31), every_months=6)
assert [d.strftime("%Y-%m-%d") for d in out][:2] == ["2024-04-01", "2024-10-01"]
def test_non_month_start_does_not_leave_early_cash(self) -> None:
"""非月初起始 + m=y=6:首个择股/调仓日不应晚于起始月,净值不得长期恒为初始值。"""
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=260)
res = LocalEngine().run_backtest(
daily, _spec(top_n=1, m=6, y=6, start=date(2024, 3, 15), end=date(2024, 12, 20))
)
first_pos = min(p.date for p in res.positions)
assert first_pos == date(2024, 3, 15), first_pos
# 起始日之后的前 20 个交易日里不应出现「净值恒为初始资金」
head = res.equity_curve[:20]
assert len({p.value for p in head}) > 1, "起始月即应建仓,净值不应恒为初始资金"
def test_selection_and_rebalance_schedules_are_independent(self) -> None:
"""m=6、y=3:择股 2 次,调仓 4 次。"""
daily = synthetic_daily({f"60000{i}.SH": 0.004 - 0.001 * i for i in range(4)}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=2, m=6, y=3))
sel_dates = sorted({p.date for p in res.selection_history})
rebal_dates_ = sorted({p.date for p in res.positions})
assert len(sel_dates) == 2, sel_dates # 2024-03-01 / 2024-09-02
assert len(rebal_dates_) == 4, rebal_dates_ # 03/06/09/12 各一次
assert set(sel_dates) < set(rebal_dates_)
def test_default_y_equals_m(self) -> None:
spec = _spec(m=6)
assert spec.effective_selection_months == 6
assert spec.effective_rebalance_months == 6 # y 缺省 → 跟随 m
def test_y_without_m_rejected(self) -> None:
with pytest.raises(ValidationError):
_spec(m=None, y=3)
class TestTwoLevelTruncation:
def test_hold_only_top_x_of_pool(self) -> None:
"""n=4、x=2:候选池记录 4 只,实际只持仓前 2 只。"""
daily = synthetic_daily({f"60000{i}.SH": 0.006 - 0.0015 * i for i in range(6)}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=4, hold_top_x=2, m=2, y=2))
pool_dates = {p.date for p in res.selection_history}
for d in pool_dates:
assert len([p for p in res.selection_history if p.date == d]) == 4
# 持仓数量不超过 x=2
per_date: dict = {}
for p in res.positions:
per_date.setdefault(p.date, []).append(p.symbol)
assert per_date and all(len(v) <= 2 for v in per_date.values())
def test_x_cannot_exceed_n(self) -> None:
with pytest.raises(ValidationError):
SelectionSpec(top_n=5, hold_top_x=10)
def test_substitute_and_defer_are_mutually_exclusive(self) -> None:
with pytest.raises(ValidationError):
SelectionSpec(top_n=5, allow_substitute=True, defer_buy=True)
def test_no_substitute_keeps_pool_membership(self) -> None:
"""defer 模式(不替补):持仓必属候选池,绝不出现池外标的。"""
daily = synthetic_daily({f"60000{i}.SH": 0.006 - 0.0015 * i for i in range(6)}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=2, hold_top_x=2, m=6, y=6))
for pos in res.positions:
same_day_pool = {p.symbol for p in res.selection_history if p.date == pos.date}
assert pos.symbol in same_day_pool, f"{pos.symbol} 不在当日候选池 {same_day_pool}"
class TestDeferBuy:
"""顺延买入:涨停当日不成交,之后首个不涨停交易日按收盘价买入。"""
def _limit_up_frame(self) -> tuple[pd.DataFrame, str, date, date]:
"""构造:600000.SH 在首个调仓日涨停,之后恢复;动量高于 600001.SH。"""
daily = synthetic_daily({"600000.SH": 0.006, "600001.SH": 0.001}, n=260)
dates = sorted(pd.to_datetime(daily["trade_date"].unique()))
d0 = next(d for d in dates if d.date() >= date(2024, 3, 1)) # 首个调仓日
d1 = dates[dates.index(d0) + 1]
prev = dates[dates.index(d0) - 1]
prev_close = float(
daily[(daily["symbol"] == "600000.SH") & (daily["trade_date"] == prev.date())][
"close"
].iloc[0]
)
mask = (daily["symbol"] == "600000.SH") & (daily["trade_date"] == d0.date())
daily.loc[mask, "close"] = prev_close * 1.10 # 主板涨停
daily.loc[mask, "high"] = prev_close * 1.10
return daily, "600000.SH", d0.date(), d1.date()
def test_buy_deferred_to_next_tradable_day(self) -> None:
daily, sym, d0, d1 = self._limit_up_frame()
res = LocalEngine().run_backtest(daily, _spec(top_n=2, hold_top_x=2, m=6, y=6))
# 调仓日意图登记为未成交,原因含「涨停」与「顺延」
rejects = [
a for a in res.signal_history
if a.date == d0 and a.symbol == sym and a.signal == "BUY" and not a.filled
]
assert rejects and "涨停" in (rejects[0].reject_reason or "")
assert "顺延" in (rejects[0].reject_reason or "")
# 当日无成交
assert not [a for a in res.fills if a.date == d0 and a.symbol == sym]
# 次一交易日按收盘价成交(价格 = 该日 close,滑点为 0)
fills = [a for a in res.fills if a.symbol == sym and a.signal == "BUY"]
assert fills, "顺延后应成交"
assert fills[0].date == d1
px = float(
daily[(daily["symbol"] == sym) & (daily["trade_date"] == d1)]["close"].iloc[0]
)
assert fills[0].price == pytest.approx(px, rel=1e-6)
def test_defer_disabled_gives_up_immediately(self) -> None:
"""defer_buy=False:涨停当日被拒后直接放弃,不顺延(区间内无下一次调仓)。"""
daily, sym, d0, d1 = self._limit_up_frame()
res = LocalEngine().run_backtest(
daily, _spec(top_n=2, hold_top_x=2, m=6, y=6, end=date(2024, 6, 28), defer_buy=False)
)
assert not [a for a in res.fills if a.symbol == sym and a.signal == "BUY"]
rejects = [
a for a in res.signal_history
if a.date == d0 and a.symbol == sym and a.signal == "BUY" and not a.filled
]
assert rejects
assert "顺延" not in (rejects[0].reject_reason or "")
def test_pending_order_cleared_at_next_rebalance(self) -> None:
"""顺延单不跨调仓:3/1 挂的单在 4/1 调仓时作废,4/1 是新的调仓尝试。"""
daily = synthetic_daily({"600000.SH": 0.006, "600001.SH": 0.001}, n=260)
dates = sorted(pd.to_datetime(daily["trade_date"].unique()))
d0 = min(d for d in dates if d.date() >= date(2024, 3, 1))
d1 = min(d for d in dates if d.date() >= date(2024, 4, 1))
# 2024-03-01 ~ 04-01 连续每日涨停(链式 +10%)
seed = dates[dates.index(d0) - 1]
prev_close = float(
daily[(daily["symbol"] == "600000.SH") & (daily["trade_date"] == seed.date())][
"close"
].iloc[0]
)
for d in dates:
if not (d0.date() <= d.date() <= d1.date()):
continue
prev_close = prev_close * 1.10
mask = (daily["symbol"] == "600000.SH") & (daily["trade_date"] == d.date())
daily.loc[mask, "close"] = prev_close
daily.loc[mask, "high"] = prev_close
res = LocalEngine().run_backtest(
daily, _spec(top_n=1, hold_top_x=1, end=date(2024, 4, 30), m=12, y=1)
)
# 3/1 与 4/1 两次调仓尝试均被涨停拒绝
reject_dates = {
a.date for a in res.signal_history
if a.symbol == "600000.SH" and a.signal == "BUY" and not a.filled
}
assert d0.date() in reject_dates and d1.date() in reject_dates
# 3/1 挂出的顺延单在 4/1 之前一次都没成交(期间每日涨停)
assert not [a for a in res.fills if a.date < d1.date()]
# 4/1 之后涨停解除 → 新一次调仓的顺延单成交
fills = [a for a in res.fills if a.symbol == "600000.SH"]
assert len(fills) == 1 and fills[0].date > d1.date()
class TestSymbolCurves:
def test_symbol_curves_and_marks(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": -0.002}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=6, y=6))
assert res.symbol_curves, "应输出个股收益曲线"
for curve in res.symbol_curves:
assert curve.points, f"{curve.symbol} 曲线无数据点"
if curve.marks:
assert all(m.symbol == curve.symbol for m in curve.marks)
assert all(m.filled for m in curve.marks)
# 成交记录中的股票都应有曲线
traded = {a.symbol for a in res.fills if a.symbol}
assert traded <= {c.symbol for c in res.symbol_curves}
def test_symbol_curve_pct_matches_holding_gain(self) -> None:
"""单股全程持有:曲线期末收益 ≈ 期末价/建仓日收盘 - 1(零成本下)。"""
daily = synthetic_daily({"600000.SH": 0.004}, n=130)
res = LocalEngine().run_backtest(
daily, _spec(top_n=1, m=12, y=12, end=date(2024, 6, 28))
)
curve = res.symbol_curves[0]
closes = daily[daily["symbol"] == "600000.SH"].sort_values("trade_date")
entry_close = float(closes[closes["trade_date"] == date(2024, 3, 1)]["close"].iloc[0])
exit_close = float(closes["close"].iloc[-1])
expected = (exit_close / entry_close - 1) * 100
assert curve.final_return_pct == pytest.approx(expected, rel=1e-3)
# 建仓当日曲线为 0%(当日收盘成交,不计当日涨跌)
assert curve.points[0].date == date(2024, 3, 1)
assert curve.points[0].value == pytest.approx(0.0, abs=1e-9)
class TestFirstDayWithoutPrevClose:
"""数据窗口起点无上一有效收盘价时的买入处理(案例 start=2020-01-01 实测命中)。
`_buyable` 在无前收时无法判定涨停:若按「不可买」处理,回测首个调仓日会被
整体放弃(顺延到次日,白付一天空仓);现按「可买」处理并在 unimplemented
中如实标注次数(AGENT.md §24)。
"""
@staticmethod
def _runner(daily: pd.DataFrame, spec: ResearchSpec):
"""用常量 score 直接驱动 Runner:绕开因子预热,隔离「首个交易日」这一场景。"""
from app.quant.local_engine import TopKBacktestRunner
close = daily.pivot(index="trade_date", columns="symbol", values="close")
close.index = pd.to_datetime(close.index)
score = pd.DataFrame(1.0, index=close.index, columns=close.columns)
return TopKBacktestRunner(spec, score, close)
def test_buy_on_first_bar_without_prev_close(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=20)
first_day = min(daily["trade_date"])
res = self._runner(
daily, _spec(top_n=1, m=1, y=1, start=first_day, end=max(daily["trade_date"]))
).run()
buy_days = [a.date for a in res.fills if a.signal == "BUY"]
assert buy_days and min(buy_days) == first_day, "首个交易日即应成交,不应被整体顺延"
assert any("无法判定涨停" in n for n in res.unimplemented), "应如实标注无前收的判定降级"
def test_no_note_when_prev_close_available(self) -> None:
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=60)
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=1, y=1))
assert not any("无法判定涨停" in n for n in res.unimplemented)
class TestSymbolCurvePayloadBound:
def test_curves_capped_with_note(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""曲线数量上限生效并如实标注(结果体积约束:超限会让落库/传输不可用)。"""
from app.quant import local_engine as le
monkeypatch.setattr(le, "_MAX_SYMBOL_CURVES", 2)
drifts = {f"60000{i}.SH": 0.001 * (i % 3) for i in range(10)}
daily = synthetic_daily(drifts, n=90)
res = LocalEngine().run_backtest(daily, _spec(top_n=10, hold_top_x=10, m=6, y=6))
assert len(res.symbol_curves) == 2
note = [n for n in res.unimplemented if "个股收益曲线" in n]
assert note and "共持有" in note[0]
def test_no_flat_points_when_not_held(self) -> None:
"""未持有期间不落点(只落持仓日 + 建仓基准点),显著压缩结果体积。"""
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=260)
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=6, y=6))
all_days = sorted(daily["trade_date"].unique())
for curve in res.symbol_curves:
point_days = {p.date for p in curve.points}
assert point_days <= set(all_days)
assert len(point_days) < len(all_days), f"{curve.symbol} 不应逐日落点"