按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 GlobalConfig(全局唯一):佣金/印花税/滑点/最低佣金/复权口径/基准
- 选股策略 SelectionStrategy(原 StrategyDefinition 改名):只剩股票池+因子+条件,
不再持有 selection/rebalance/costs/portfolio/区间/资金
- 回测组合 BacktestCombo:引用若干选股策略 + 回测时才定的参数
(起始资金、持仓数 N、持仓天数区间 [Tmin,Tmax]、调仓时机 日/周/月、区间)
引擎(app/quant/combo_engine.py,新增):
- 多策略打分 = 并集 + Borda 秩和(各策略 1/名次 求和;不假设不同策略分值可比,
能容纳各策略股票池不同);抽出纯函数 borda_combine 便于单测
- 持仓天数区间 [Tmin,Tmax]:Tmax **每个交易日**强制了结(安全阀,月频下也不超期);
Tmin 仅在调仓日保护(掉出 TopN 但未满 Tmin 暂留,防频繁换手);调仓日为增量调仓
(只卖超期/掉队且满 Tmin 的,从 TopN 补买至 N 只,不主动减持以尊重 Tmin)
- 调仓时机 daily/weekly/monthly(local_engine.rebalance_dates 新增日频分支)
- 产出与旧 runner 同构的 BacktestResult,前端可视化无需改动;config_snapshot 固化
ComboRunSpec(组合+当时各策略定义+当时成本/复权)保证可复现
数据层:
- 新表 global_config(默认行:万三/hfq/最低佣金5元)、backtest_combo
- 迁移 b4c5d6e7f8a9:建两表 + 把存量 strategy.config_json 的回测参数键剥掉、
spec_type 收敛为 selection(已在真实 MariaDB 验证:STG-16BFBF08 清洗后只剩
universe/factors/conditions)
- 仓储 SqlAlchemyGlobalConfigRepository / SqlAlchemyComboRepository + Protocol
API:
- /api/config GET/PUT;/api/combos CRUD + /{id}/run + /run(kind=combo 异步 Job)
- job_executor 新增 combo 分支:取齐策略+读公共配置→ComboService.run,归档 kind
记 backtest(结果结构相同)
- /api/strategies 切到 SelectionStrategy,移除已废弃的 /{id}/expand
- strategy_doc.describe_strategy 支持 SelectionStrategy(只讲「怎么选」,如实声明
资金/持仓/调仓/成本/区间在回测组合里定)
旧的 ResearchSpec + /api/backtests 保留(因子测试与既有契约自检仍用),
作为底层 escape hatch;用户产品路径改为回测组合。
测试:新增 test_combo_engine(6)/test_combo_service(3)/test_combo_api(5),
改写 test_strategies/test_strategy_doc 适配新模型。全量 403 passed(原 388)。
196 lines
8.3 KiB
Python
196 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)),
|
||
)
|