feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化

汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+544
View File
@@ -0,0 +1,544 @@
"""策略说明书生成器(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 StrategyDefinition
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_strategy_definition_input_uses_placeholder_period(self) -> None:
st = StrategyDefinition(
name="演示",
factors=[FactorSpec(name="momentum_60", weight=1.0)],
selection=SelectionSpec(top_n=10),
)
doc = describe_strategy(st)
assert doc.summary and doc.formula and doc.steps
assert "momentum_60" in doc.formula
assert "1900" not 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 = StrategyDefinition(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"):
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 any("无回测区间" in w for w in doc["warnings"])
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)` 之内。
超长在 SQLite(测试库)不会报错、到 MySQL 严格模式会 Data too long,
因此这里显式断言列宽;截断必须带省略号(显式标记,不静默改短)。
"""
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
assert desc.endswith("…"), "超长被截断时必须显式带省略号"
# 常规(单因子)说明远短于列宽:不应被截断
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}],
"selection": {"top_n": 10},
},
)
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}],
"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"] == "改后的说明"
# 再读一次:落库后的创建时间同样未变,且列表仍只有一条
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"]