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:
@@ -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"] == "改后的说明"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user