字段库(本次新增的表与接口): - `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。
175 lines
6.6 KiB
Python
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"]
|