Files
qlib/backend/tests/test_factor_catalog.py
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
2026-10-01 16:33:32 +08:00

199 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M7.1 因子目录测试:factor_definition 落库(幂等 upsert)+ /api/factors 读库 + seed。
repo 测试走 tmp SQLite;API 测试 override get_session 到 tmp sqlite 种子库。
"""
from __future__ import annotations
import pytest
from app.api import deps
from app.domain.entities.factor import FactorDefinition
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.factors import list_factors
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture()
def session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'factor.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
with Session() as s:
yield s
class TestFactorRepository:
def test_upsert_idempotent_and_roundtrip(self, session) -> None:
repo = SqlAlchemyFactorRepository(session)
d = FactorDefinition(
name="test_momentum", description="测试", formula="x", brief="b",
lookback=10, requires=["close", "high"],
)
assert repo.upsert_many([d]) == 1
session.commit()
assert len(repo.list()) == 1
got = repo.get("test_momentum")
assert got is not None and got.requires == ["close", "high"]
# 幂等更新
repo.upsert_many([d.model_copy(update={"description": "更新"})])
session.commit()
assert repo.get("test_momentum").description == "更新"
assert len(repo.list()) == 1
def test_seed_from_registry(self, session) -> None:
repo = SqlAlchemyFactorRepository(session)
defs = [FactorDefinition.from_registry_def(d) for d in list_factors()]
assert repo.upsert_many(defs) == len(defs)
session.commit()
names = {f.name for f in repo.list()}
assert len(names) == len(defs)
# 与注册表一致
assert names == {d.name for d in list_factors()}
assert repo.get("momentum_60").direction == "higher_is_better"
@pytest.fixture()
def client(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'api.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()
class TestFactorsApi:
def test_list_seeds_and_reads_db(self, client) -> None:
resp = client.get("/api/factors")
assert resp.status_code == 200
rows = resp.json()
assert isinstance(rows, list) and len(rows) >= 9
first = next(r for r in rows if r["name"] == "momentum_60")
# DB 契约源字段齐全(与前端 FactorMeta 匹配 + version)
assert set(first.keys()) >= {
"name", "description", "brief", "formula", "frequency",
"lookback", "direction", "requires", "version",
}
assert "close" in first["requires"]
def test_list_matches_registry_after_seed(self, client) -> None:
"""seed 后目录 == 代码注册表集合(无额外未知项)。"""
client.get("/api/factors") # 首次访问触发 seed
client.get("/api/factors") # 幂等:二次访问不报错、不重复
resp = client.get("/api/factors")
names = {r["name"] for r in resp.json()}
assert names == {d.name for d in list_factors()}
class TestRegistrySyncRegression:
"""回归:表非空时也必须补齐「注册表有、库里没有」的因子。
历史 bug:seed 只在表为空时触发,导致 `dividend_yield` 等后加的因子永远不进目录
(实测真实库表里 9 条、注册表 11 条),前端因子下拉与归档说明都取不到它们。
"""
def test_missing_registry_factor_is_seeded_when_table_not_empty(self, client, tmp_path) -> None:
from sqlalchemy import text
# 1) 先正常读一次 → 目录完整(含股息率因子)
full = {r["name"] for r in client.get("/api/factors").json()}
assert "dividend_yield" in full, "注册表里的股息率因子必须出现在目录中"
# 2) 人为删除一行,复现「表非空但缺因子」的历史状态
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
with engine.begin() as conn:
conn.execute(text("DELETE FROM factor_definition WHERE name = 'dividend_yield'"))
rows = client.get("/api/factors").json()
# 3) 再读 → 缺失因子被当场补齐
names = {r["name"] for r in rows}
assert "dividend_yield" in names, "表非空时也必须补齐缺失的注册表因子"
assert names == {d.name for d in list_factors()}
# 4) 用户登记的自定义因子元数据不被覆盖/删除(只补不删)
Session = sessionmaker(bind=engine, expire_on_commit=False)
with Session() as s2:
SqlAlchemyFactorRepository(s2).upsert_many(
[
FactorDefinition(
name="my_custom_factor",
description="自定义因子",
formula="x",
brief="b",
frequency="daily",
lookback=5,
direction="higher_is_better",
requires=[],
version="1",
)
]
)
s2.commit()
names2 = {r["name"] for r in client.get("/api/factors").json()}
assert "my_custom_factor" in names2, "只补不删:自定义因子必须保留"
assert "dividend_yield" in names2
class TestCatalogIsRegistryProjection:
"""目录是注册表的投影(2026-10 明确语义):口径字段按代码纠正,自定义行不碰。
原先是「只在缺名字时才 upsert 全量」,于是手改内置因子文案能存活到「代码里出现
新因子」那一刻再被无声覆盖 —— 行为不确定。现在改成按字段差集确定性收敛。
"""
def test_hand_edited_registry_row_converges_back_to_code(self, client, tmp_path) -> None:
from app.quant.factors import get_factor
from sqlalchemy import create_engine, text
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
with engine.begin() as conn:
conn.execute(
text(
"UPDATE factor_definition SET description = '手改的假口径', "
"lookback = 999, direction = 'lower_is_better' "
"WHERE name = 'dividend_yield'"
)
)
rows = {r["name"]: r for r in client.get("/api/factors").json()}
defn, _ = get_factor("dividend_yield")
got = rows["dividend_yield"]
assert got["description"] == defn.description, "口径文案必须按代码改回(否则说明书会撒谎)"
assert got["lookback"] == defn.lookback
assert got["direction"] == defn.direction, "方向被手改会让读者以为越大越差"
def test_steady_state_writes_nothing(self, session) -> None:
from app.application.services.factor_catalog import sync_registry_factors
repo = SqlAlchemyFactorRepository(session)
assert sync_registry_factors(repo, session) == len(list_factors()), "首次:全部补齐"
assert sync_registry_factors(repo, session) == 0, "稳态:目录 == 注册表 → 零写入"
def test_custom_row_untouched_and_never_deleted(self, session) -> None:
from app.application.services.factor_catalog import sync_registry_factors
repo = SqlAlchemyFactorRepository(session)
sync_registry_factors(repo, session)
repo.upsert_many(
[
FactorDefinition(
name="my_note_factor", description="人工登记的备注", formula="x",
brief="b", lookback=5, requires=[],
)
]
)
session.commit()
assert sync_registry_factors(repo, session) == 0, "自定义行不算 stale,不触发写入"
got = repo.get("my_note_factor")
assert got is not None and got.description == "人工登记的备注"
assert got.name not in {d.name for d in list_factors()}, "前提:它确实不在注册表里"