feat(backend): 策略库重构为「选股策略 + 公共配置 + 回测组合」三件套
按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""组合回测引擎单测(合成数据,确定性,不依赖数据库/因子注册表)。
|
||||
|
||||
验证三件用户确认的语义:
|
||||
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)),
|
||||
)
|
||||
Reference in New Issue
Block a user