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:
Simon
2026-09-30 21:43:28 +08:00
parent 50a1030afa
commit 40bd603b44
25 changed files with 2250 additions and 174 deletions
+15 -17
View File
@@ -24,7 +24,7 @@ from app.domain.entities.research import (
SelectionSpec,
UniverseSpec,
)
from app.domain.entities.strategy import StrategyDefinition
from app.domain.entities.strategy import SelectionStrategy
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.main import app
from app.quant.factors import FactorDef
@@ -301,17 +301,19 @@ class TestStepsAndWarnings:
class TestDefinitionInput:
def test_strategy_definition_input_uses_placeholder_period(self) -> None:
st = StrategyDefinition(
def test_selection_strategy_describes_only_selection(self) -> None:
"""选股策略说明只讲「怎么选」,不假装知道回测参数(重构后无 period/costs/selection)。"""
st = SelectionStrategy(
name="演示",
factors=[FactorSpec(name="momentum_60", weight=1.0)],
selection=SelectionSpec(top_n=10),
conditions=[{"field": "dv_ratio", "op": "lte", "value": 30}],
)
doc = describe_strategy(st)
assert doc.summary and doc.formula and doc.steps
assert "选股策略" in doc.summary
assert "momentum_60" in doc.formula
assert "1900" not in doc.formula, "占位区间不得泄漏到展示文本"
assert any("无回测区间" in w for w in doc.warnings)
# 如实声明回测参数不在策略内
assert any("回测组合" in w for w in doc.warnings)
def test_real_spec_has_no_placeholder_warning(self) -> None:
doc = describe_strategy(_spec())
@@ -325,13 +327,13 @@ class TestDefinitionInput:
describe_strategy(spec)
assert spec.model_dump() == before
st = StrategyDefinition(name="演示", factors=[FactorSpec(name="momentum_60")])
st = SelectionStrategy(name="演示", factors=[FactorSpec(name="momentum_60")])
before_st = st.model_dump()
describe_strategy(st)
assert st.model_dump() == before_st
def test_unsupported_input_raises_clear_type_error(self) -> None:
with pytest.raises(TypeError, match="ResearchSpec 或 StrategyDefinition"):
with pytest.raises(TypeError, match="ResearchSpec 或 SelectionStrategy"):
describe_strategy({"factors": []}) # type: ignore[arg-type]
@@ -391,7 +393,7 @@ class TestStrategyDocApi:
assert resp.status_code == 200
doc = resp.json()
assert doc["summary"]
assert any("无回测区间" in w for w in doc["warnings"])
assert "选股策略" in doc["summary"]
assert client.get("/api/strategies/STG-NOT-EXIST/describe").status_code == 404
def test_post_fills_empty_description(self, client: TestClient) -> None:
@@ -428,8 +430,9 @@ class TestStrategyDocApi:
def test_auto_description_fits_column_width(self, client: TestClient) -> None:
"""自动说明必须落在 `StrategyModel.description = String(300)` 之内。
超长在 SQLite(测试库)不会报错、到 MySQL 严格模式会 Data too long,
因此这里显式断言列宽;截断必须带省略号(显式标记,不静默改短)。
重构后选股策略的说明只讲「怎么选」,天然简洁(不再拼回测公式),
即使挂满全部因子也远低于列宽 —— 这里断言「一定放得下」即可;
截断分支(超长带省略号)由 ResearchSpec 路径保留,选股策略触达不到。
"""
from app.api.strategies import _DESCRIPTION_MAX_CHARS
from app.quant.factors import list_factors
@@ -440,10 +443,8 @@ class TestStrategyDocApi:
)
assert resp.status_code == 200
desc = resp.json()["description"]
assert len(desc) <= _DESCRIPTION_MAX_CHARS
assert desc.endswith("…"), "超长被截断时必须显式带省略号"
assert len(desc) <= _DESCRIPTION_MAX_CHARS, "选股策略说明也必须落在列宽内"
# 常规(单因子)说明远短于列宽:不应被截断
normal = client.post(
"/api/strategies",
json={"name": "单因子策略", "factors": [{"name": "dividend_yield", "weight": 1}]},
@@ -460,7 +461,6 @@ class TestStrategyUpdateApi:
"name": name,
"description": "初始说明",
"factors": [{"name": "momentum_60", "weight": 1}],
"selection": {"top_n": 10},
},
)
assert resp.status_code == 200
@@ -478,14 +478,12 @@ class TestStrategyUpdateApi:
"name": "策略A",
"description": "改后的说明",
"factors": [{"name": "momentum_20", "weight": 2}],
"selection": {"top_n": 5},
},
)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == sid, "原地更新必须保持 id 不变(不新建)"
assert body["created_at"] == created_at, "PUT 不得刷新创建时间"
assert body["selection"]["top_n"] == 5
assert body["factors"][0]["name"] == "momentum_20"
assert body["description"] == "改后的说明"