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。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
@@ -38,6 +39,7 @@ def test_upgrade_head_creates_phase1_tables(tmp_path) -> None:
|
||||
"trading_calendar",
|
||||
"financial_indicator",
|
||||
"sync_log",
|
||||
"condition_field",
|
||||
"alembic_version",
|
||||
}
|
||||
assert expected <= tables
|
||||
@@ -63,3 +65,110 @@ def test_upgrade_head_idempotent(tmp_path) -> None:
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user