按用户目标把原来「一个策略 = 全套参数」拆开(已确认的设计决策):
- 公共配置 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)。
543 lines
24 KiB
Python
543 lines
24 KiB
Python
"""策略说明书生成器(app.quant.strategy_doc)测试 + 策略说明/公式/编辑接口测试。
|
||
|
||
目标(AGENT.md §24/§31):说明书必须由 spec **真实推导**,并且
|
||
- 条件表达式的渲染必须与 `app.quant.selection._eval_condition` 的实际求值一致
|
||
(含 ref 语义、缺失值语义、in/not_in 的列表语义);
|
||
- 步骤必须与 `app.quant.local_engine` 的真实行为一致(择股日/调仓日/顺延/期末);
|
||
- 未知因子、未建模约束一律进 warnings(不编造、不假装支持)。
|
||
|
||
同一文件还覆盖策略 API 的说明接口与「原地更新(PUT)」,因为三者共用同一契约。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
import pandas as pd
|
||
import pytest
|
||
from app.api import deps
|
||
from app.domain.entities.research import (
|
||
ConditionSpec,
|
||
CostSpec,
|
||
FactorSpec,
|
||
ResearchSpec,
|
||
SelectionSpec,
|
||
UniverseSpec,
|
||
)
|
||
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
|
||
from app.quant.selection import build_condition_fields, eligible_symbols
|
||
from app.quant.strategy_doc import describe_strategy
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from conftest_quant import synthetic_daily
|
||
|
||
|
||
def _spec(**kw) -> ResearchSpec:
|
||
"""高股息案例参数(n=30 → x=20、m=y=6、hfq、显式成本与最低佣金)。"""
|
||
base = dict(
|
||
type="backtest",
|
||
universe=UniverseSpec(exclude_st=False, min_listing_days=250),
|
||
price_adjustment="hfq",
|
||
factors=[FactorSpec(name="dividend_yield", weight=1.0)],
|
||
conditions=[ConditionSpec(field="dv_ratio", op="lte", value=30)],
|
||
selection=SelectionSpec(top_n=30, hold_top_x=20, allow_substitute=False, defer_buy=True),
|
||
rebalance="monthly",
|
||
selection_interval_months=6,
|
||
rebalance_interval_months=6,
|
||
period=(date(2020, 1, 1), date(2024, 12, 31)),
|
||
costs=CostSpec(min_commission=5.0),
|
||
)
|
||
base.update(kw)
|
||
return ResearchSpec(**base)
|
||
|
||
|
||
class TestSummary:
|
||
def test_summary_contains_factors_truncation_cycles_and_costs(self) -> None:
|
||
doc = describe_strategy(_spec())
|
||
s = doc.summary
|
||
assert "股息率" in s, "summary 必须由因子元数据推导出因子含义"
|
||
assert "dividend_yield" in s
|
||
assert "前 30 只候选池" in s and "前 20 只等权" in s, "必须说清两级截断 n → x"
|
||
assert "每 6 个月重新择股" in s and "每 6 个月调仓" in s, "必须说清 m / y"
|
||
assert "后复权" in s and "收盘价成交" in s
|
||
assert "佣金 0.03%" in s and "印花税 0.05%" in s and "滑点 0.1%" in s
|
||
|
||
def test_summary_lower_is_better_factor_方向(self) -> None:
|
||
doc = describe_strategy(_spec(factors=[FactorSpec(name="volatility_20", weight=1.0)]))
|
||
assert "从低到高排序" in doc.summary
|
||
|
||
def test_summary_without_hold_top_x_uses_n(self) -> None:
|
||
doc = describe_strategy(
|
||
_spec(selection=SelectionSpec(top_n=20, allow_substitute=False), conditions=[])
|
||
)
|
||
assert "前 20 只等权持有" in doc.summary
|
||
assert "先通过" not in doc.summary, "无 conditions 时不得声称有过滤条件"
|
||
|
||
def test_summary_states_buy_fallback_mode(self) -> None:
|
||
"""买不进的处置决定实际持仓,必须出现在一句话说明里(三种模式各一)。"""
|
||
defer = describe_strategy(_spec(conditions=[]))
|
||
assert "买不进则顺延买入" in defer.summary
|
||
subst = describe_strategy(
|
||
_spec(selection=SelectionSpec(top_n=5, allow_substitute=True), conditions=[])
|
||
)
|
||
assert "从候选池之外替补" in subst.summary
|
||
none = describe_strategy(
|
||
_spec(selection=SelectionSpec(top_n=5, allow_substitute=False), conditions=[])
|
||
)
|
||
assert "买不进则放弃" in none.summary
|
||
|
||
|
||
class TestFormula:
|
||
def test_formula_has_factor_name_meaning_and_formula(self) -> None:
|
||
doc = describe_strategy(_spec())
|
||
f = doc.formula
|
||
assert "dividend_yield" in f
|
||
# 因子元数据(AGENT.md §22)逐项落进公式:含义 / 公式 / 方向 / 输入列
|
||
assert "含义:股息率" in f
|
||
assert "公式:dv_ratio(Tushare daily_basic,逐日时点值)" in f
|
||
assert "越高越好" in f and "higher_is_better" in f
|
||
assert "输入列:dv_ratio" in f
|
||
assert "score_i = Σ_f w_f × d_f × z_f,i" in f
|
||
assert "z_f,i = (x_f,i - mean_i(x_f)) / std_i(x_f)" in f
|
||
|
||
def test_formula_has_two_level_truncation_and_cycles(self) -> None:
|
||
f = describe_strategy(_spec()).formula
|
||
assert "n = 30" in f and "x = 20" in f
|
||
assert "每 6 个月" in f
|
||
assert "择股日" in f and "调仓日" in f
|
||
|
||
def test_formula_has_costs_and_execution_price_basis(self) -> None:
|
||
f = describe_strategy(_spec()).formula
|
||
assert "佣金 = max(投入资金 × 0.03%, 最低佣金 5 元/笔)" in f
|
||
assert "印花税 0.05%(仅卖出)" in f, "印花税只对卖出计提(local_engine._rebalance)"
|
||
assert "滑点 0.1%" in f
|
||
assert "后复权" in f and "乘以 adjust_factor" not in f # 口径文案由 _ADJUST_TEXT 给出
|
||
assert "成交时点:调仓日收盘" in f
|
||
assert "1,000,000 元" in f
|
||
|
||
def test_formula_renders_conditions_readably(self) -> None:
|
||
spec = _spec(
|
||
conditions=[
|
||
ConditionSpec(field="dv_ratio", op="lte", value=30),
|
||
ConditionSpec(field="close", op="gte", ref="ma60"),
|
||
ConditionSpec(field="fundamental.roe", op="gte", value=15),
|
||
ConditionSpec(field="static.industry", op="in", value=["白酒", "银行"]),
|
||
ConditionSpec(field="static.industry", op="ne", value="白酒"),
|
||
ConditionSpec(field="pe", op="lt", value=30.5),
|
||
]
|
||
)
|
||
f = describe_strategy(spec).formula
|
||
assert "dv_ratio <= 30" in f
|
||
assert "close >= ma60" in f, "ref 条件必须渲染成「字段 op 字段」(同一股票同一日比较)"
|
||
assert "fundamental.roe >= 15" in f
|
||
assert 'static.industry ∈ ["白酒", "银行"]' in f
|
||
assert 'static.industry != "白酒"' in f
|
||
assert "pe < 30.5" in f
|
||
# 说明字段域语义,避免使用者误以为 fundamental.* 是当日值
|
||
assert "announce_date <= 择股日" in f
|
||
|
||
def test_empty_conditions_does_not_crash(self) -> None:
|
||
doc = describe_strategy(_spec(conditions=[]))
|
||
assert "(无)" in doc.formula
|
||
assert any("未配置过滤条件" in w for w in doc.warnings)
|
||
assert doc.steps
|
||
|
||
def test_unknown_factor_goes_to_warnings_not_crash(self) -> None:
|
||
doc = describe_strategy(_spec(factors=[FactorSpec(name="no_such_factor", weight=2.0)]))
|
||
assert any("未知因子 no_such_factor" in w for w in doc.warnings)
|
||
assert "元数据缺失" in doc.formula
|
||
assert doc.summary # 仍然可用(说明不因未知因子而失败)
|
||
|
||
def test_factor_meta_override_is_used(self) -> None:
|
||
meta = {
|
||
"my_signal": FactorDef(
|
||
name="my_signal", description="自定义信号", formula="close / ma20",
|
||
brief="实验因子", direction="lower_is_better", requires=("close",),
|
||
)
|
||
}
|
||
doc = describe_strategy(
|
||
_spec(factors=[FactorSpec(name="my_signal", weight=0.5)], conditions=[]),
|
||
factor_meta=meta,
|
||
)
|
||
assert "自定义信号" in doc.formula and "公式:close / ma20" in doc.formula
|
||
assert "越低越好" in doc.formula
|
||
assert not any("未知因子" in w for w in doc.warnings)
|
||
assert "从低到高排序" in doc.summary
|
||
|
||
def test_unknown_condition_field_goes_to_warnings(self) -> None:
|
||
doc = describe_strategy(
|
||
_spec(conditions=[ConditionSpec(field="no_such_col", op="gt", value=1)])
|
||
)
|
||
assert any("条件字段 no_such_col 未识别" in w for w in doc.warnings)
|
||
assert "no_such_col > 1" in doc.formula
|
||
|
||
|
||
class TestConditionSemanticsMatchSelectionEvaluator:
|
||
"""渲染文本必须与 selection 的真实求值一致(以代码为准,不以注释/直觉为准)。"""
|
||
|
||
def test_gte_ref_and_lte_value_match_eligible_symbols(self) -> None:
|
||
syms = ["600000.SH", "600001.SH", "600002.SH"]
|
||
daily = synthetic_daily({"600000.SH": 0.006, "600001.SH": -0.006, "600002.SH": 0.0005}, n=300)
|
||
obs = pd.Timestamp("2024-12-31")
|
||
conds = [
|
||
ConditionSpec(field="close", op="gte", ref="ma60"),
|
||
ConditionSpec(field="momentum_20", op="lte", value=0.05),
|
||
]
|
||
fields = build_condition_fields(daily, conds, obs)
|
||
passed = eligible_symbols(sorted(syms), conds, {}, fields, {})
|
||
|
||
# 期望值直接按「渲染出的表达式」的语义计算,与求值器结果对齐
|
||
for sym in syms:
|
||
expected = bool(
|
||
fields["close"][sym] >= fields["ma60"][sym]
|
||
and fields["momentum_20"][sym] <= 0.05
|
||
)
|
||
assert (sym in passed) == expected, f"{sym} 的渲染语义与求值器不一致"
|
||
|
||
# 且文档里出现的就是这两条表达式
|
||
doc = describe_strategy(_spec(conditions=conds))
|
||
assert "close >= ma60" in doc.formula
|
||
assert "momentum_20 <= 0.05" in doc.formula
|
||
|
||
def test_in_not_in_and_missing_value_semantics(self) -> None:
|
||
"""in/not_in 的右操作数是列表,语义为 `left in right`;缺失值语义见下。"""
|
||
conds = [
|
||
ConditionSpec(field="static.industry", op="in", value=["白酒"]),
|
||
ConditionSpec(field="static.pe", op="ne", value=10), # 缺失字段
|
||
]
|
||
statics = {"A": {"industry": "白酒"}, "B": {"industry": "银行"}}
|
||
passed = eligible_symbols(["A", "B"], conds, statics, {}, {})
|
||
# 代码事实(selection._compare):op == "ne" 在 None 判定**之前**返回 left != right,
|
||
# 因此字段缺失时 `ne` 判定为「不等于」→ 通过;其余运算符在缺失值上不通过。
|
||
assert set(passed) == {"A"}
|
||
doc = describe_strategy(_spec(conditions=conds))
|
||
assert 'static.industry ∈ ["白酒"]' in doc.formula
|
||
assert "static.pe != 10" in doc.formula
|
||
assert "除 != 外一律判为「未通过」" in doc.formula
|
||
|
||
def test_string_comparison_is_lexicographic_in_real_evaluator(self) -> None:
|
||
"""对照 selection._compare 的实际实现,修正其内联注释的说法。
|
||
|
||
`_compare` 的注释写「字符串会 ValueError → False」,但代码里 gt/gte/lt/lte
|
||
最终走 `_num_cmp(left, right, op)`,而 `_num_cmp` **不做 float 转换**,
|
||
因此两个字符串是按 Python 原生(字典序)比较的,并不会 ValueError。
|
||
说明书按代码事实渲染为 `static.industry > "M"`(同一运算符、同一语义)。
|
||
"""
|
||
conds = [ConditionSpec(field="static.industry", op="gt", value="M")]
|
||
statics = {"A": {"industry": "白酒"}} # '白酒' > 'M'(Unicode 码位更大)
|
||
passed = eligible_symbols(["A"], conds, statics, {}, {})
|
||
assert set(passed) == {"A"}
|
||
assert 'static.industry > "M"' in describe_strategy(_spec(conditions=conds)).formula
|
||
|
||
|
||
class TestStepsAndWarnings:
|
||
def test_steps_follow_local_engine_behaviour(self) -> None:
|
||
doc = describe_strategy(_spec())
|
||
joined = " | ".join(doc.steps)
|
||
assert "300 个自然日" in joined, "预热窗口来自 service._load_daily"
|
||
assert "候选池" in joined and "selection_history" in joined
|
||
assert "收盘" in joined, "成交发生在调仓日收盘"
|
||
assert "涨停" in joined and "停牌" in joined
|
||
assert "顺延" in joined, "defer_buy=True 必须如实说明顺延买入"
|
||
assert "次日起计收益" in joined, "调仓日收盘生效(无未来函数)"
|
||
assert "不强制平仓" in joined, "期末不平仓是引擎的真实行为,必须披露"
|
||
|
||
def test_steps_describe_substitute_mode(self) -> None:
|
||
doc = describe_strategy(
|
||
_spec(selection=SelectionSpec(top_n=5, allow_substitute=True), conditions=[])
|
||
)
|
||
assert any("替补" in s for s in doc.steps)
|
||
|
||
def test_universe_step_conditional_and_time_accurate(self) -> None:
|
||
"""股票池步骤只写实际生效的过滤,且时点口径必须正确(起始日过滤一次)。
|
||
|
||
以 service._load_daily / universe.filter_stocks 的代码为准:universe 过滤在
|
||
as_of=回测起始日执行一次,之后只有 exclude_st 会按当日名称逐择股日重判。
|
||
"""
|
||
doc = describe_strategy(_spec(universe=UniverseSpec(min_listing_days=250), conditions=[]))
|
||
pool_step = doc.steps[1]
|
||
assert "回测起始日按 universe 过滤一次" in pool_step
|
||
assert "当日名称" in pool_step
|
||
assert "指数成分" not in pool_step, "未配置 index_code 时不得声称有指数成分过滤"
|
||
|
||
idx = describe_strategy(
|
||
_spec(
|
||
universe=UniverseSpec(index_code="000300.SH", exclude_st=False, min_listing_days=0),
|
||
conditions=[],
|
||
)
|
||
)
|
||
assert "000300.SH 的当日历史成分" in idx.steps[1]
|
||
assert "名称含 ST" not in idx.steps[1]
|
||
|
||
def test_warnings_cover_unmodelled_constraints(self) -> None:
|
||
# exclude_st 默认 True(UniverseSpec 默认值)时才需要标注时点/快照口径
|
||
doc = describe_strategy(_spec(universe=UniverseSpec(min_listing_days=250)))
|
||
joined = " | ".join(doc.warnings)
|
||
assert "exclude_suspended" in joined, "停牌未建模必须标注"
|
||
assert "名称变更历史" in joined, "exclude_st 时点/快照口径必须标注"
|
||
assert "幸存者偏差" in joined
|
||
assert "涨跌停" in joined
|
||
|
||
def test_warnings_flag_none_adjustment_and_industry_cap(self) -> None:
|
||
from app.domain.entities.research import PortfolioSpec
|
||
|
||
doc = describe_strategy(
|
||
_spec(price_adjustment="none", portfolio=PortfolioSpec(max_industry_weight_pct=0.3))
|
||
)
|
||
joined = " | ".join(doc.warnings)
|
||
assert "不复权" in joined
|
||
assert "未建模" in joined and "行业权重" in joined
|
||
|
||
def test_stale_pool_warning_when_y_lt_m(self) -> None:
|
||
doc = describe_strategy(
|
||
_spec(selection_interval_months=12, rebalance_interval_months=3, conditions=[])
|
||
)
|
||
assert any("池子陈旧" in w for w in doc.warnings)
|
||
|
||
|
||
class TestDefinitionInput:
|
||
def test_selection_strategy_describes_only_selection(self) -> None:
|
||
"""选股策略说明只讲「怎么选」,不假装知道回测参数(重构后无 period/costs/selection)。"""
|
||
st = SelectionStrategy(
|
||
name="演示",
|
||
factors=[FactorSpec(name="momentum_60", weight=1.0)],
|
||
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 any("回测组合" in w for w in doc.warnings)
|
||
|
||
def test_real_spec_has_no_placeholder_warning(self) -> None:
|
||
doc = describe_strategy(_spec())
|
||
assert not any("无回测区间" in w for w in doc.warnings)
|
||
assert "2020-01-01 ~ 2024-12-31" in doc.formula
|
||
|
||
def test_describe_does_not_mutate_input(self) -> None:
|
||
"""纯函数约定:只生成文本,不改写 spec(更不改写研究结果)。"""
|
||
spec = _spec()
|
||
before = spec.model_dump()
|
||
describe_strategy(spec)
|
||
assert spec.model_dump() == before
|
||
|
||
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 或 SelectionStrategy"):
|
||
describe_strategy({"factors": []}) # type: ignore[arg-type]
|
||
|
||
|
||
# ---------- 策略 API:说明接口 + 原地更新(PUT) ----------
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(tmp_path) -> TestClient:
|
||
engine = create_engine(f"sqlite:///{tmp_path / 'stratdoc.db'}", future=True)
|
||
Base.metadata.create_all(engine)
|
||
Session = sessionmaker(bind=engine, expire_on_commit=False)
|
||
|
||
def _session_override():
|
||
with Session() as s:
|
||
yield s
|
||
|
||
app.dependency_overrides[deps.get_session] = _session_override
|
||
with TestClient(app) as c:
|
||
yield c
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
_SPEC_BODY = {
|
||
"type": "backtest",
|
||
"universe": {"exclude_st": False, "min_listing_days": 0},
|
||
"factors": [{"name": "dividend_yield", "weight": 1.0}],
|
||
"selection": {"top_n": 20, "hold_top_x": 20, "allow_substitute": False, "defer_buy": True},
|
||
"rebalance": "monthly",
|
||
"selection_interval_months": 6,
|
||
"rebalance_interval_months": 6,
|
||
"period": ["2020-01-01", "2024-12-31"],
|
||
}
|
||
|
||
|
||
class TestStrategyDocApi:
|
||
def test_post_describe_from_research_spec_without_saving(self, client: TestClient) -> None:
|
||
resp = client.post("/api/strategies/describe", json=_SPEC_BODY)
|
||
assert resp.status_code == 200
|
||
doc = resp.json()
|
||
assert set(doc) == {"summary", "formula", "steps", "warnings"}
|
||
assert "dividend_yield" in doc["formula"]
|
||
assert "每 6 个月" in doc["summary"]
|
||
# 未保存:列表仍为空(POST /describe 与 POST /strategies 不冲突)
|
||
assert client.get("/api/strategies").json() == []
|
||
|
||
def test_post_describe_invalid_spec_422(self, client: TestClient) -> None:
|
||
bad = dict(_SPEC_BODY, period=["2024-01-01", "2020-01-01"])
|
||
assert client.post("/api/strategies/describe", json=bad).status_code == 422
|
||
|
||
def test_get_describe_saved_strategy_and_404(self, client: TestClient) -> None:
|
||
created = client.post(
|
||
"/api/strategies",
|
||
json={"name": "高股息", "factors": [{"name": "dividend_yield", "weight": 1}]},
|
||
)
|
||
sid = created.json()["id"]
|
||
resp = client.get(f"/api/strategies/{sid}/describe")
|
||
assert resp.status_code == 200
|
||
doc = resp.json()
|
||
assert doc["summary"]
|
||
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:
|
||
"""需求:策略必须有说明 —— description 为空/纯空白时由 summary 自动补全。"""
|
||
for desc in ("", " "):
|
||
resp = client.post(
|
||
"/api/strategies",
|
||
json={
|
||
"name": f"无说明策略-{len(desc)}",
|
||
"description": desc,
|
||
"factors": [{"name": "momentum_60", "weight": 1}],
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["description"].strip()
|
||
assert "momentum_60" in body["description"]
|
||
# 响应中的说明与 GET 读回一致(真的落库了)
|
||
assert client.get(f"/api/strategies/{body['id']}").json()["description"] == body[
|
||
"description"
|
||
]
|
||
|
||
def test_post_keeps_explicit_description(self, client: TestClient) -> None:
|
||
resp = client.post(
|
||
"/api/strategies",
|
||
json={
|
||
"name": "有说明策略",
|
||
"description": "我自己写的说明",
|
||
"factors": [{"name": "momentum_60", "weight": 1}],
|
||
},
|
||
)
|
||
assert resp.json()["description"] == "我自己写的说明"
|
||
|
||
def test_auto_description_fits_column_width(self, client: TestClient) -> None:
|
||
"""自动说明必须落在 `StrategyModel.description = String(300)` 之内。
|
||
|
||
重构后选股策略的说明只讲「怎么选」,天然简洁(不再拼回测公式),
|
||
即使挂满全部因子也远低于列宽 —— 这里断言「一定放得下」即可;
|
||
截断分支(超长带省略号)由 ResearchSpec 路径保留,选股策略触达不到。
|
||
"""
|
||
from app.api.strategies import _DESCRIPTION_MAX_CHARS
|
||
from app.quant.factors import list_factors
|
||
|
||
all_factors = [{"name": f.name, "weight": 1} for f in list_factors()]
|
||
resp = client.post(
|
||
"/api/strategies", json={"name": "全因子策略", "factors": all_factors}
|
||
)
|
||
assert resp.status_code == 200
|
||
desc = resp.json()["description"]
|
||
assert len(desc) <= _DESCRIPTION_MAX_CHARS, "选股策略说明也必须落在列宽内"
|
||
|
||
normal = client.post(
|
||
"/api/strategies",
|
||
json={"name": "单因子策略", "factors": [{"name": "dividend_yield", "weight": 1}]},
|
||
).json()["description"]
|
||
assert len(normal) <= _DESCRIPTION_MAX_CHARS
|
||
assert not normal.endswith("…")
|
||
|
||
|
||
class TestStrategyUpdateApi:
|
||
def _create(self, client: TestClient, name: str = "策略A") -> dict:
|
||
resp = client.post(
|
||
"/api/strategies",
|
||
json={
|
||
"name": name,
|
||
"description": "初始说明",
|
||
"factors": [{"name": "momentum_60", "weight": 1}],
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
return resp.json()
|
||
|
||
def test_put_updates_in_place_and_keeps_created_at(self, client: TestClient) -> None:
|
||
created = self._create(client)
|
||
sid = created["id"]
|
||
created_at = created["created_at"]
|
||
assert created_at is not None
|
||
|
||
resp = client.put(
|
||
f"/api/strategies/{sid}",
|
||
json={
|
||
"name": "策略A",
|
||
"description": "改后的说明",
|
||
"factors": [{"name": "momentum_20", "weight": 2}],
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["id"] == sid, "原地更新必须保持 id 不变(不新建)"
|
||
assert body["created_at"] == created_at, "PUT 不得刷新创建时间"
|
||
assert body["factors"][0]["name"] == "momentum_20"
|
||
assert body["description"] == "改后的说明"
|
||
|
||
# 再读一次:落库后的创建时间同样未变,且列表仍只有一条
|
||
assert client.get(f"/api/strategies/{sid}").json()["created_at"] == created_at
|
||
assert len(client.get("/api/strategies").json()) == 1
|
||
|
||
def test_put_path_id_wins_over_body_id(self, client: TestClient) -> None:
|
||
created = self._create(client, "策略B")
|
||
sid = created["id"]
|
||
resp = client.put(
|
||
f"/api/strategies/{sid}",
|
||
json={
|
||
"id": "STG-FAKE",
|
||
"name": "策略B",
|
||
"factors": [{"name": "momentum_60", "weight": 1}],
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["id"] == sid
|
||
assert client.get("/api/strategies/STG-FAKE").status_code == 404
|
||
|
||
def test_put_missing_strategy_404(self, client: TestClient) -> None:
|
||
resp = client.put(
|
||
"/api/strategies/STG-NOT-EXIST",
|
||
json={"name": "X", "factors": [{"name": "momentum_60", "weight": 1}]},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert "不存在" in resp.json()["detail"]
|
||
|
||
def test_put_duplicate_name_400(self, client: TestClient) -> None:
|
||
first = self._create(client, "策略C")
|
||
second = self._create(client, "策略D")
|
||
resp = client.put(
|
||
f"/api/strategies/{second['id']}",
|
||
json={"name": "策略C", "factors": [{"name": "momentum_60", "weight": 1}]},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "策略名已存在" in resp.json()["detail"]
|
||
# 撞车失败后第二条策略名不变(未被部分写入)
|
||
assert client.get(f"/api/strategies/{second['id']}").json()["name"] == "策略D"
|
||
assert client.get(f"/api/strategies/{first['id']}").json()["name"] == "策略C"
|
||
|
||
def test_put_fills_empty_description(self, client: TestClient) -> None:
|
||
created = self._create(client, "策略E")
|
||
resp = client.put(
|
||
f"/api/strategies/{created['id']}",
|
||
json={
|
||
"name": "策略E",
|
||
"description": " ",
|
||
"factors": [{"name": "dividend_yield", "weight": 1}],
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["description"].strip()
|
||
assert "dividend_yield" in resp.json()["description"]
|