"""策略说明书生成器(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"]