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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user