字段库(本次新增的表与接口): - `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。
195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""组合回测引擎单测(合成数据,确定性,不依赖数据库/因子注册表)。
|
||
|
||
验证三件用户确认的语义:
|
||
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)),
|
||
)
|