"""因子参数化测试(2026-10):模板 + 参数 + 参数化键 + 目录/API 行为。 覆盖四件事(每件都是「错了会静默算错」的地方): 1. **参数真生效**:窗口改了因子值真的变、方向改了排序真的反过来; 2. **键即身份**:规范键可解析、非规范/越界/缺参一律当场拒绝(不靠默认值兜底); 3. **目录分工**:内置实例口径按代码收敛、参数化实例的开关是人配的(不被同步冲掉)、 算不出来的历史行保留但标 resolvable=False; 4. **过滤条件**:参数化因子能当条件字段用,且单位后缀不会瞎猜。 """ from __future__ import annotations import pytest from app.api import deps from app.application.services.factor_catalog import ( create_parameterized_factor, sync_registry_factors, ) from app.domain.entities.factor import FactorDefinition from app.domain.entities.research import FactorSpec from app.infrastructure.persistence.sqlalchemy.base import Base from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import ( SqlAlchemyFactorRepository, ) from app.main import app from app.quant.composite import build_score_panel from app.quant.condition_fields import get_field, reason_unsupported from app.quant.factors import ( FactorError, canonical_key, compute_factor, get_factor, get_template, is_resolvable, list_factors, list_templates, parse_factor_key, ) from app.quant.selection import condition_needed_columns from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from conftest_quant import synthetic_daily @pytest.fixture() def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'param.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) with Session() as s: yield s @pytest.fixture() def client(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'param_api.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) def _override(): with Session() as s: yield s app.dependency_overrides[deps.get_session] = _override with TestClient(app) as c: yield c app.dependency_overrides.clear() class TestParamsTakeEffect: """参数不是装饰:改了必须真的影响引擎算出来的东西。""" def test_window_changes_values_and_lookback(self) -> None: daily = synthetic_daily({"AAA": 0.002, "BBB": -0.001}, n=200) d20, p20 = compute_factor("momentum_20", daily) d90, p90 = compute_factor( "momentum(window=90,direction=higher_is_better)", daily ) assert d20.lookback == 20 and d90.lookback == 90 # 90 日动量覆盖更长区间:上涨股的累计涨幅必须更大 assert float(p90.iloc[-1]["AAA"]) > float(p20.iloc[-1]["AAA"]) # 且两者不是同一序列(窗口参数真的进了计算,而不是只写进名字) assert not p20.iloc[-1].equals(p90.iloc[-1]) def test_direction_flips_the_score_ranking(self) -> None: daily = synthetic_daily({"AAA": 0.002, "BBB": 0.0, "CCC": -0.002}, n=160) hi = build_score_panel( daily, [FactorSpec(name="momentum(window=90,direction=higher_is_better)")] ).iloc[-1] lo = build_score_panel( daily, [FactorSpec(name="momentum(window=90,direction=lower_is_better)")] ).iloc[-1] assert float(hi["AAA"]) > float(hi["CCC"]) assert float(lo["CCC"]) > float(lo["AAA"]), "方向改成越低越好后,排序必须反过来" def test_formula_and_description_render_the_params(self) -> None: defn, _fn = get_factor("momentum(window=90,direction=higher_is_better)") assert "90" in defn.description and "90" in defn.formula assert defn.label == "动量(窗口 90,越高越好)" assert defn.template == "momentum" assert defn.params == {"window": 90, "direction": "higher_is_better"} assert defn.source == "custom" def test_builtin_instances_keep_their_legacy_metadata(self) -> None: """老名字必须还按原来的口径算:归档/策略/文档里到处是它们。""" assert get_factor("momentum_60")[0].lookback == 60 assert get_factor("momentum_60")[0].direction == "higher_is_better" assert get_factor("volatility_20")[0].direction == "lower_is_better" assert get_factor("volume_ratio_5_60")[0].lookback == 60 assert get_factor("dividend_yield")[0].lookback == 0 assert {d.name for d in list_factors()} >= {"momentum_20", "dividend_yield_ttm"} class TestCanonicalKeys: def test_canonical_key_lists_every_param(self) -> None: key = canonical_key("momentum", {"window": 90}) assert key == "momentum(window=90,direction=higher_is_better)" # 写全参数的意义:键自解释,不依赖模板默认值(默认值改了也不会让老键变义) assert canonical_key("volatility", {"window": 30}) == ( "volatility(window=30,direction=lower_is_better)" ) assert canonical_key("volume_ratio", {"fast": 10, "slow": 120}) == ( "volume_ratio(fast=10,slow=120,direction=higher_is_better)" ) def test_roundtrip(self) -> None: key = "volume_ratio(fast=10,slow=120,direction=lower_is_better)" template, params = parse_factor_key(key) assert template.name == "volume_ratio" assert canonical_key(template, params) == key assert is_resolvable(key) @pytest.mark.parametrize( "bad,why", [ ("momentum(window=90)", "缺 direction(键不许依赖默认值)"), ("momentum(window=1,direction=higher_is_better)", "窗口低于下限"), ("momentum(window=999,direction=higher_is_better)", "窗口高于上限"), ("momentum(window=abc,direction=higher_is_better)", "窗口不是整数"), ("momentum(window=90,direction=upper)", "方向不是枚举值"), ("momentum(window=90,direction=higher_is_better,foo=1)", "多给了参数"), ("volume_ratio(fast=60,slow=5,direction=higher_is_better)", "快线不小于慢线"), ("no_such_template(window=5,direction=higher_is_better)", "模板不存在"), ("momentum_20(window=5,direction=higher_is_better)", "实例名不能带参数"), ("not_a_factor", "既不是实例名也不是参数化键"), ], ) def test_bad_keys_are_rejected(self, bad: str, why: str) -> None: with pytest.raises(FactorError): get_factor(bad) # noqa: PT011 - 只关心「拒绝」,文案另有断言 def test_error_messages_tell_the_allowed_range(self) -> None: with pytest.raises(FactorError, match="2 ~ 500"): get_factor("momentum(window=999,direction=higher_is_better)") with pytest.raises(FactorError, match="规范写法"): get_factor("momentum(window=90)") with pytest.raises(FactorError, match="快线窗口"): get_factor("volume_ratio(fast=9,slow=9,direction=higher_is_better)") def test_key_length_fits_the_column(self) -> None: """最长的合法键也要能进 factor_definition.name(String(128))—— 不然是运行期报错。""" longest = canonical_key("volume_ratio", {"fast": 499, "slow": 500}) assert len(longest) < 128 def test_templates_expose_editable_params(self) -> None: tpls = {t.name: t for t in list_templates()} assert "momentum" in tpls and "dividend_yield" in tpls specs = {s.name: s for s in tpls["momentum"].specs()} assert specs["window"].kind == "int" and specs["window"].maximum == 500 assert specs["direction"].choices == ("higher_is_better", "lower_is_better") # 股息率没有窗口参数:只有方向可编辑(不许凭空造出「窗口」) dy = {s.name for s in get_template("dividend_yield").specs()} assert dy == {"direction"} class TestCatalogAndApi: def test_create_then_read_rows_are_engine_projection(self, client) -> None: rows = {r["name"]: r for r in client.get("/api/factors").json()} assert rows["momentum_60"]["label"] == "动量(窗口 60,越高越好)" assert rows["momentum_60"]["params"] == { "window": 60, "direction": "higher_is_better", } assert rows["momentum_60"]["source"] == "builtin" assert {s["name"] for s in rows["momentum_60"]["param_specs"]} == { "window", "direction", } created = client.post("/api/factors", json={"template": "momentum", "params": {"window": 90}}) assert created.status_code == 201, created.text body = created.json() key = "momentum(window=90,direction=higher_is_better)" assert body["name"] == key and body["label"] == "动量(窗口 90,越高越好)" assert body["source"] == "custom" and body["resolvable"] is True again = client.get("/api/factors").json() assert key in {r["name"] for r in again} def test_create_rejects_bad_params_and_duplicates(self, client) -> None: client.get("/api/factors") bad = client.post("/api/factors", json={"template": "momentum", "params": {"window": 0}}) assert bad.status_code == 422 and "2 ~ 500" in bad.json()["detail"] unknown = client.post("/api/factors", json={"template": "nope", "params": {}}) assert unknown.status_code == 422 and "未知因子模板" in unknown.json()["detail"] first = client.post("/api/factors", json={"template": "reversal", "params": {"window": 6}}) assert first.status_code == 201 dup = client.post("/api/factors", json={"template": "reversal", "params": {"window": 6}}) assert dup.status_code == 422 and "已存在" in dup.json()["detail"] def test_templates_endpoint(self, client) -> None: tpls = {t["name"]: t for t in client.get("/api/factors/templates").json()} assert tpls["momentum"]["defaults"] == { "window": 20, "direction": "higher_is_better", } assert tpls["momentum"]["instances"] == ["momentum_20", "momentum_60", "momentum_120"] def test_disable_only_affects_pickability(self, client) -> None: client.get("/api/factors") key = "momentum(window=77,direction=higher_is_better)" client.post("/api/factors", json={"template": "momentum", "params": {"window": 77}}) off = client.patch("/api/factors", json={"name": key, "enabled": False}) assert off.status_code == 200 and off.json()["enabled"] is False # 再读一次:同步不会把人的开关冲掉 rows = {r["name"]: r for r in client.get("/api/factors").json()} assert rows[key]["enabled"] is False # 停用不影响引擎解析(历史策略/归档照样能算) assert get_factor(key)[0].lookback == 77 def test_builtin_cannot_be_disabled(self, client) -> None: client.get("/api/factors") resp = client.patch("/api/factors", json={"name": "momentum_60", "enabled": False}) assert resp.status_code == 422 and "内置因子" in resp.json()["detail"] def test_patch_unknown_factor_is_404(self, client) -> None: client.get("/api/factors") resp = client.patch( "/api/factors", json={"name": "ma_bias(window=13,direction=higher_is_better)", "enabled": False}, ) assert resp.status_code == 404 def test_engine_text_converges_for_parameterized_rows(self, session) -> None: """参数化实例的口径文案同样按代码收敛,但 enabled 是人的配置、不许冲掉。""" repo = SqlAlchemyFactorRepository(session) sync_registry_factors(repo, session) key = "momentum(window=88,direction=higher_is_better)" row = create_parameterized_factor(repo, session, template="momentum", params={"window": 88}) assert row.name == key # 手改文案 + 停用 → 同步后:文案回到代码文本,开关保留 repo.upsert_many( [row.model_copy(update={"description": "手改的假口径", "enabled": False})] ) session.commit() assert sync_registry_factors(repo, session) >= 1 got = repo.get(key) assert got.description == get_factor(key)[0].description assert got.enabled is False, "停用是人配的,不能被代码投影冲回 True" assert sync_registry_factors(repo, session) == 0, "再来一次应稳态零写入" def test_unresolvable_hand_row_is_kept_and_flagged(self, session) -> None: repo = SqlAlchemyFactorRepository(session) sync_registry_factors(repo, session) repo.upsert_many( [ FactorDefinition( name="someone_typo_factor", description="人工登记的备注", formula="x", brief="b", lookback=5, requires=[], ) ] ) session.commit() assert sync_registry_factors(repo, session) == 0, "算不出来的行不参与收敛" got = repo.get("someone_typo_factor") assert got is not None and got.description == "人工登记的备注" assert not is_resolvable("someone_typo_factor") class TestParameterizedFactorAsCondition: """因子既能打分也能过滤:参数化实例在条件路径上同样要能用、文案不撒谎。""" def test_resolvable_and_no_bogus_unit(self) -> None: key = "momentum(window=45,direction=lower_is_better)" assert reason_unsupported(key) == "" field = get_field(key) assert field is not None assert field.kind == "num" assert field.unit == "", "因子是无量纲量:不许给它挂单位(挂错了会误导输入)" assert "动量" in field.label def test_condition_needed_columns_accepts_parameterized_key(self) -> None: from app.domain.entities.research import ConditionSpec class _Q: def __init__(self, conds): self.conditions = conds need = condition_needed_columns( _Q([ConditionSpec(field="volume_ratio(fast=3,slow=9,direction=higher_is_better)", op="gte", value=1)]) ) assert "volume" in need, "参数化量比因子必须把 volume 列带进装配" def test_unknown_field_message_mentions_parameterized_keys(self) -> None: from app.domain.entities.research import ConditionSpec class _Q: def __init__(self, conds): self.conditions = conds with pytest.raises(ValueError, match="参数化因子键"): condition_needed_columns(_Q([ConditionSpec(field="nope", op="gte", value=1)])) def test_docs_render_the_parameterized_name(self) -> None: from app.domain.entities.strategy import SelectionStrategy from app.quant.strategy_doc import describe_strategy key = "momentum(window=45,direction=higher_is_better)" doc = describe_strategy( SelectionStrategy(name="参数化因子演示", factors=[FactorSpec(name=key, weight=1.0)]) ) joined = "\n".join([doc.formula, *doc.steps]) assert key in joined, "说明书要写清用的是哪个参数版本(否则读者不知道窗口是多少)" assert not any("未知因子" in w for w in doc.warnings)