Files
qlib/backend/tests/test_migrations.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

175 lines
6.6 KiB
Python

"""Alembic 迁移测试:全新数据库 upgrade head 后应包含全部 Phase 1 表。"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from alembic import command
from alembic.config import Config
BACKEND_ROOT = Path(__file__).resolve().parents[1]
def _alembic_config(db_path: Path) -> Config:
cfg = Config(str(BACKEND_ROOT / "alembic.ini"))
cfg.set_main_option("script_location", "app/infrastructure/persistence/migrations")
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
return cfg
def test_upgrade_head_creates_phase1_tables(tmp_path) -> None:
db_path = tmp_path / "fresh.db"
command.upgrade(_alembic_config(db_path), "head")
con = sqlite3.connect(db_path)
try:
tables = {
row[0]
for row in con.execute("select name from sqlite_master where type='table'").fetchall()
}
finally:
con.close()
expected = {
"stock",
"stock_daily",
"adjust_factor",
"trading_calendar",
"financial_indicator",
"sync_log",
"condition_field",
"alembic_version",
}
assert expected <= tables
# 关键防未来函数列存在
con = sqlite3.connect(db_path)
try:
fin_cols = {
row[1] for row in con.execute("pragma table_info(financial_indicator)").fetchall()
}
daily_cols = {row[1] for row in con.execute("pragma table_info(stock_daily)").fetchall()}
finally:
con.close()
assert {"report_date", "announce_date"} <= fin_cols
assert {"symbol", "trade_date", "close"} <= daily_cols
# 来源标记列(新浪校验兜底行需与 Tushare 首选行区分)
assert "source" in fin_cols
assert {"source", "adjust"} <= daily_cols
def test_upgrade_head_idempotent(tmp_path) -> None:
db_path = tmp_path / "again.db"
cfg = _alembic_config(db_path)
command.upgrade(cfg, "head")
command.upgrade(cfg, "head") # 二次执行不报错
def test_refresh_stale_strategy_descriptions(tmp_path) -> None:
"""存量策略的陈旧说明被重算,人工说明保留,空说明补全(b4c5… → c5d6… 全链路)。
模拟真实历史:在「策略表已建、还没做组合重构」的版本上插入带全套回测参数的旧行,
一路 upgrade head —— 既验证 b4c5d6e7f8a9 剥掉配置键,也验证 c5d6e7f8a9 重算说明。
"""
db_path = tmp_path / "refresh.db"
cfg = _alembic_config(db_path)
command.upgrade(cfg, "e1f2a3b4c5d6") # strategy 表建好、尚未重构
stale_desc = (
"全市场(剔除 ST),按股息率排序取出前 20 只等权持有,每 6 个月重新择股、"
"每 6 个月调仓,后复权口径、按调仓日收盘价成交(含佣金 0.03%/印花税 0.05%/滑点 0.1%)。"
)
base_cfg = {
"universe": {"market": "CN_A", "exclude_st": True},
"factors": [{"name": "dividend_yield", "weight": 1}],
"conditions": [],
}
legacy_cfg = {**base_cfg, "selection": {"top_n": 20}, "rebalance": "monthly",
"costs": {"commission_rate": 0.0003}, "price_adjustment": "hfq"}
con = sqlite3.connect(db_path)
con.execute(
"INSERT INTO strategy (id,name,description,spec_type,config_json,version,created_at) "
"VALUES (?,?,?,?,?,?,?)",
("STG-STALE", "高股息 Top20(案例口径)", stale_desc, "backtest",
json.dumps(legacy_cfg, ensure_ascii=False), "1", "2026-01-01 00:00:00"),
)
con.execute(
"INSERT INTO strategy (id,name,description,spec_type,config_json,version,created_at) "
"VALUES (?,?,?,?,?,?,?)",
("STG-HUMAN", "我的成长股", "只看 ROE 与动量,人工撰写的说明不要被覆盖。", "backtest",
json.dumps({**base_cfg, "factors": [{"name": "momentum_60", "weight": 1}]},
ensure_ascii=False), "1", "2026-01-02 00:00:00"),
)
con.execute(
"INSERT INTO strategy (id,name,description,spec_type,config_json,version,created_at) "
"VALUES (?,?,?,?,?,?,?)",
("STG-EMPTY", "空说明策略", "", "backtest",
json.dumps(base_cfg, ensure_ascii=False), "1", "2026-01-03 00:00:00"),
)
con.commit()
con.close()
command.upgrade(cfg, "head")
con = sqlite3.connect(db_path)
try:
rows = {
r[0]: {"desc": r[1], "spec": r[2], "cfg": r[3]}
for r in con.execute(
"SELECT id, description, spec_type, config_json FROM strategy"
).fetchall()
}
tables = {
r[0] for r in con.execute(
"select name from sqlite_master where type='table'"
).fetchall()
}
finally:
con.close()
# ① 陈旧自动说明被重算:不再提佣金/印花税/滑点/调仓,且是新口径文案
stale = rows["STG-STALE"]
assert stale["desc"] != stale_desc
for marker in ("佣金", "印花税", "滑点", "调仓", "择股", "复权口径"):
assert marker not in stale["desc"], f"陈旧说明仍含 {marker}: {stale['desc']}"
assert "选股策略" in stale["desc"] and "回测组合" in stale["desc"]
# ② 人工撰写的说明原样保留(迁移不覆盖用户文本)
assert rows["STG-HUMAN"]["desc"] == "只看 ROE 与动量,人工撰写的说明不要被覆盖。"
# ③ 空说明按当前口径补全
assert rows["STG-EMPTY"]["desc"].strip()
assert "选股策略" in rows["STG-EMPTY"]["desc"]
# ④ b4c5… 的职责仍在:旧回测参数从 config_json 剥掉、spec_type 收敛
stripped = json.loads(rows["STG-STALE"]["cfg"])
assert "costs" not in stripped and "rebalance" not in stripped and "selection" not in stripped
assert stripped["factors"][0]["name"] == "dividend_yield"
assert rows["STG-STALE"]["spec"] == "selection"
assert {"global_config", "backtest_combo"} <= tables
def test_condition_field_table_columns(tmp_path) -> None:
"""字段库表结构:name 主键 + 中文名/含义/类型/分组/来源/启用状态(2026-10)。"""
db_path = tmp_path / "fields.db"
command.upgrade(_alembic_config(db_path), "head")
con = sqlite3.connect(db_path)
try:
cols = {row[1] for row in con.execute("pragma table_info(condition_field)").fetchall()}
pk = [
row[1]
for row in con.execute("pragma table_info(condition_field)").fetchall()
if row[5]
]
finally:
con.close()
assert {
"name", "label", "description", "kind", "group_name",
"unit", "source", "enabled", "sort_order", "created_at", "updated_at",
} <= cols
assert pk == ["name"]