Files
qlib/backend/tests/test_combo_engine.py
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
2026-10-01 16:33:32 +08:00

195 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合回测引擎单测(合成数据,确定性,不依赖数据库/因子注册表)。
验证三件用户确认的语义:
1. Borda 秩和打分:两策略排名不同 → 综合排序可手算预测。
2. 持仓天数区间 [Tmin, Tmax]:超 Tmax 强制了结;未满 Tmin 即使掉出 TopN 也暂留。
3. 调仓时机 daily/weekly/monthly 产生不同的调仓次数。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
import pytest
from app.domain.entities.combo import BacktestCombo
from app.domain.entities.research import CostSpec
from app.quant.combo_engine import HoldingBandRunner, borda_combine
def _business_days(start: date, n: int) -> list[date]:
"""生成 n 个连续工作日(跳过周末),用作合成行情索引。"""
out: list[date] = []
d = start
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d += timedelta(days=1)
return out
def _flat_close(symbols: list[str], days: list[date], price: float = 100.0) -> pd.DataFrame:
"""所有股票恒定价格的面板(收益为 0,便于隔离「选股/调仓」逻辑)。"""
idx = pd.to_datetime(days)
return pd.DataFrame(price, index=idx, columns=symbols)
# ---------- 1. Borda 秩和 ----------
def test_borda_combine_hand_computed():
"""两策略排名不同,综合分 = Σ(1/名次),可手算。"""
day = pd.Timestamp("2024-01-02")
# 策略1:A > B > C;策略2:C > A > B
p1 = pd.DataFrame({"A": [3.0], "B": [2.0], "C": [1.0]}, index=[day])
p2 = pd.DataFrame({"A": [2.0], "B": [1.0], "C": [3.0]}, index=[day])
combined = borda_combine([p1, p2]).loc[day]
# A: 1/1 + 1/2 = 1.5;C: 1/3 + 1/1 = 1.333;B: 1/2 + 1/3 = 0.833
assert combined["A"] == pytest.approx(1.5)
assert combined["C"] == pytest.approx(1.0 / 3 + 1.0)
assert combined["B"] == pytest.approx(1.0 / 2 + 1.0 / 3)
order = combined.sort_values(ascending=False).index.tolist()
assert order == ["A", "C", "B"] # 并集后统一排序:A、C 进 Top2,B 落选
def test_borda_missing_symbol_contributes_zero():
"""某策略面板里没有某股票(NaN)→ 该策略对它贡献 0,但不影响其它策略的贡献。"""
day = pd.Timestamp("2024-01-02")
p1 = pd.DataFrame({"A": [3.0], "B": [2.0]}, index=[day]) # 策略1 只有 A、B
p2 = pd.DataFrame({"A": [1.0], "C": [2.0]}, index=[day]) # 策略2 只有 A、C
combined = borda_combine([p1, p2]).loc[day]
assert combined["A"] == pytest.approx(1.0 + 1.0 / 2) # 两策略都覆盖 A
assert combined["B"] == pytest.approx(1.0 / 2) # 只被策略1 覆盖
assert combined["C"] == pytest.approx(1.0) # 只被策略2 覆盖(在其面板里排第 1)
# ---------- 2. 持仓天数区间 ----------
def _make_combo(**overrides) -> BacktestCombo:
base = dict(
name="t", strategy_ids=["S1"], initial_capital=1_000_000.0,
hold_count=1, hold_min_days=0, hold_max_days=None,
rebalance_freq="daily", period=(date(2024, 1, 2), date(2024, 1, 31)),
)
base.update(overrides)
return BacktestCombo(**base)
def test_tmax_force_exit_respected():
"""恒价 + N=1 + 永远选 A + Tmax=5 + 日频:A 持有超过 5 天即被强制卖出再买回,
任何一笔交易的持有天数都不应明显超过 Tmax。"""
symbols = ["A", "B", "C"]
days = _business_days(date(2024, 1, 2), 30)
close = _flat_close(symbols, days)
# A 永远最高分 → 永远 Top1
score = pd.DataFrame(
{"A": [3.0] * len(days), "B": [2.0] * len(days), "C": [1.0] * len(days)},
index=pd.to_datetime(days),
)
combo = _make_combo(hold_count=1, hold_min_days=0, hold_max_days=5, rebalance_freq="daily")
runner = HoldingBandRunner(
combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close,
)
result = runner.run()
assert result.trades, "应产生交易"
# 交易日索引:用引擎同一口径(交易日)验证「任何一笔持仓都不超过 Tmax 个交易日」
tday_pos = {pd.Timestamp(d): i for i, d in enumerate(close.index)}
def trading_span(a, b):
return tday_pos[pd.Timestamp(b)] - tday_pos[pd.Timestamp(a)]
for t in result.trades:
span = trading_span(t.entry_date, t.exit_date)
# 卖出发生在「held > Tmax」的第一个交易日 → 跨度最多 Tmax+1 个交易日
assert span <= 5 + 1, f"持仓跨 {span} 个交易日 > Tmax+1,Tmax 安全阀失效:{t}"
# 确实反复「卖后再买」—— 证明 Tmax 在强制换手,而不是一直死拿
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert len(buys) >= 4, f"Tmax=5 在 30 个交易日内应触发多次重买,实际仅 {len(buys)} 次"
def test_tmin_protects_against_churn():
"""N=1,第 2 天起 B 变成最高分(A 掉出 Top1),但 Tmin=10 → A 在满 10 天前不被卖出。"""
symbols = ["A", "B"]
days = _business_days(date(2024, 1, 2), 20)
close = _flat_close(symbols, days)
# 第 0 天 A 最高;第 1 天起 B 最高
a_scores = [3.0] + [1.0] * (len(days) - 1)
b_scores = [1.0] + [3.0] * (len(days) - 1)
score = pd.DataFrame({"A": a_scores, "B": b_scores}, index=pd.to_datetime(days))
combo = _make_combo(hold_count=1, hold_min_days=10, hold_max_days=None, rebalance_freq="daily")
runner = HoldingBandRunner(
combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close,
)
result = runner.run()
# A 应在第 0 天买入
a_buys = [a for a in result.signal_history if a.symbol == "A" and a.signal == "BUY" and a.filled]
assert a_buys, "A 应在首日买入"
a_sells = [t for t in result.trades if t.symbol == "A"]
if a_sells:
# 若最终卖出,持有天数必须 ≥ Tmin(不能在满 10 天前因掉出 TopN 被卖)
for t in a_sells:
assert (t.exit_date - t.entry_date).days >= 10, (
f"A 仅持 {(t.exit_date - t.entry_date).days} 天就被卖,违反 Tmin=10 保护"
)
# 关键断言:前 9 个工作日内 A 不应被卖出(Tmin 保护生效)
tday_pos = {pd.Timestamp(d): i for i, d in enumerate(close.index)}
early_sells = [
a for a in result.signal_history
if a.symbol == "A" and a.signal == "SELL" and a.filled
and tday_pos[pd.Timestamp(a.date)] - tday_pos[pd.Timestamp(days[0])] < 10
]
assert not early_sells, f"Tmin 保护失效:A 在 10 个交易日内被卖出 {early_sells}"
# ---------- 3. 调仓时机 ----------
def test_rebalance_freq_changes_cadence():
"""同一份数据,daily 的调仓日数 > weekly > monthly(用 selection_history 的 distinct 日期数衡量)。"""
symbols = ["A", "B", "C"]
days = _business_days(date(2024, 1, 2), 60)
close = _flat_close(symbols, days)
score = pd.DataFrame(
{"A": [3.0] * len(days), "B": [2.0] * len(days), "C": [1.0] * len(days)},
index=pd.to_datetime(days),
)
def run_with(freq: str) -> int:
combo = _make_combo(
hold_count=2, rebalance_freq=freq,
period=(days[0], days[-1]),
)
r = HoldingBandRunner(
combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close,
).run()
return len({p.date for p in r.selection_history})
daily_n, weekly_n, monthly_n = run_with("daily"), run_with("weekly"), run_with("monthly")
assert daily_n > weekly_n > monthly_n, (
f"调仓频次应 daily({daily_n}) > weekly({weekly_n}) > monthly({monthly_n})"
)
# ---------- 实体校验 ----------
def test_combo_validates_hold_band_and_freq():
with pytest.raises(ValueError):
BacktestCombo(
name="x", strategy_ids=["A"], hold_count=1,
hold_min_days=20, hold_max_days=5, # Tmax < Tmin
rebalance_freq="monthly", period=(date(2024, 1, 1), date(2024, 2, 1)),
)
with pytest.raises(ValueError):
BacktestCombo(
name="x", strategy_ids=["A"], hold_count=1,
rebalance_freq="yearly", # 非法频率
period=(date(2024, 1, 1), date(2024, 2, 1)),
)
with pytest.raises(ValueError):
BacktestCombo(
name="x", strategy_ids=["A", "A"], hold_count=1, # 重复策略
rebalance_freq="monthly", period=(date(2024, 1, 1), date(2024, 2, 1)),
)