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:
Simon
2026-10-01 16:33:32 +08:00
parent 40bd603b44
commit 2e90f3eeac
39 changed files with 3280 additions and 244 deletions
+67 -18
View File
@@ -43,6 +43,46 @@ def _day(text: str) -> date:
return date.fromisoformat(text)
def _split_factor_list(raw: str) -> list[str]:
"""按逗号切因子列表,但**不切参数化因子键里的逗号**。
参数化因子的名字把参数写全了(`momentum(window=90,direction=higher_is_better)`),
直接 `.split(",")` 会把它劈成「momentum(window=90」和「direction=…):0.7」两段,
模型与用户只会收到「因子不存在」这种看不懂的错。括号深度感知的切分让两种写法都能用:
momentum_60,volatility_60
momentum(window=90,direction=lower_is_better),volatility_60
"""
out: list[str] = []
depth = 0
buf: list[str] = []
for ch in raw:
if ch == "(":
depth += 1
elif ch == ")":
depth = max(0, depth - 1)
if ch == "," and depth == 0:
out.append("".join(buf).strip())
buf = []
else:
buf.append(ch)
out.append("".join(buf).strip())
return [x for x in out if x]
def _split_name_weight(part: str) -> tuple[str, str]:
"""把 `name:weight` 按**括号外**的第一个冒号切开(参数化键里的 `=`/`,` 不受影响)。"""
depth = 0
for i, ch in enumerate(part):
if ch == "(":
depth += 1
elif ch == ")":
depth = max(0, depth - 1)
elif ch == ":" and depth == 0:
return part[:i].strip(), part[i + 1 :].strip()
return part.strip(), ""
def _pick(mapping: dict, key: str, default=None):
val = mapping.get(key, default)
if isinstance(val, str):
@@ -147,7 +187,7 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
return _run_spec(spec, f"因子 {name} 测试")
def run_backtest(args: dict) -> str:
factor_names = [f.strip() for f in str(_pick(args, "factors", "momentum_60")).split(",")]
factor_names = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
top_n = int(_pick(args, "top_n", 5) or 5)
rebalance = str(_pick(args, "rebalance", "monthly"))
exclude_st = bool(_pick(args, "exclude_st", True))
@@ -206,7 +246,7 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
return [x.strip().upper() for x in raw.split(",") if x.strip()][:60]
def screen_stocks(args: dict) -> str:
factors = [x.strip() for x in str(_pick(args, "factors", "momentum_60")).split(",") if x.strip()]
factors = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
top_n = int(_pick(args, "top_n", 10) or 10)
as_of = _day(str(_pick(args, "as_of", date.today().isoformat())))
symbols = _scope_symbols(str(_pick(args, "symbols", "") or ""))
@@ -256,7 +296,7 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
return "\n".join(out)
def generate_signals(args: dict) -> str:
factors = [x.strip() for x in str(_pick(args, "factors", "momentum_60")).split(",") if x.strip()]
factors = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
as_of = _day(str(_pick(args, "as_of", date.today().isoformat())))
symbols = _scope_symbols(str(_pick(args, "symbols", "") or ""))
query = SelectionQuery(
@@ -288,9 +328,8 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
if not name:
return "请提供 name"
factors = [
{"name": x.strip(), "weight": 1.0}
for x in str(_pick(args, "factors", "momentum_60")).split(",")
if x.strip()
{"name": x, "weight": 1.0}
for x in _split_factor_list(str(_pick(args, "factors", "momentum_60")))
]
if not factors:
return "请提供至少一个 factors(逗号分隔)"
@@ -316,28 +355,38 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
def inspect_factor(args: dict) -> str:
name = str(_pick(args, "name", ""))
# 先问引擎:目录里有没有这行是「管理」问题,引擎算不算得出来才是「能不能用」。
# 参数化因子(momentum(window=90,direction=…))经常还没进目录就被引用,也能算。
try:
defn, _fn = get_factor(name)
except FactorError as exc:
return f"因子不可用:{exc}"
with session_factory() as session:
row = SqlAlchemyFactorRepository(session).get(name)
if row is None:
return f"因子 {name} 不在目录(可用列表:GET /api/factors)"
params = ",".join(f"{k}={v}" for k, v in defn.params.items())
head = f"{defn.label}({defn.name})" if defn.label else defn.name
return (
f"{row.name}:{row.description}\n公式:{row.formula}\n方向:"
f"{'越高越好' if row.direction == 'higher_is_better' else '越低越好'}"
f"(lookback {row.lookback},输入 {row.requires})\n简介:{row.brief}"
f"{head}:{defn.description}\n公式:{defn.formula}\n方向:"
f"{'越高越好' if defn.direction == 'higher_is_better' else '越低越好'}"
f"(lookback {defn.lookback},输入 {defn.requires})\n"
f"参数:{params or '(无:内置实例名固定口径)'}\n"
f"来源:{'代码注册表内置' if defn.source == 'builtin' else '目录里的参数化实例'}"
f"{';在目录中已停用(仍可被引用)' if row is not None and not row.enabled else ''}\n"
f"简介:{defn.brief}"
)
def create_composite_factor(args: dict) -> str:
name = str(_pick(args, "name", ""))
raw = str(_pick(args, "factors", ""))
if not name or not raw:
return "请提供 name 与 factors(格式:momentum_60:0.7,volatility_60:0.3)"
return (
"请提供 name 与 factors(格式:momentum_60:0.7,volatility_60:0.3;"
"参数化因子写成 momentum(window=90,direction=lower_is_better):0.7)"
)
comps: list[CompositeComponent] = []
for part in raw.split(","):
if not part.strip():
continue
seg = part.strip().split(":")
fname = seg[0].strip()
weight = float(seg[1]) if len(seg) > 1 and seg[1].strip() else 1.0
for part in _split_factor_list(raw):
fname, weight_text = _split_name_weight(part)
weight = float(weight_text) if weight_text else 1.0
if not fname:
continue
try: