From 2e90f3eeac76504e89ac1270d424c29d70561d82 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 16:33:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=AD=97=E6=AE=B5=E5=BA=93?= =?UTF-8?q?=EF=BC=88condition=5Ffield=EF=BC=89+=20=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=8C=96=EF=BC=88=E6=A8=A1=E6=9D=BF/?= =?UTF-8?q?=E5=8F=97=E6=8E=A7=E5=8F=82=E6=95=B0=EF=BC=89+=20=E5=8D=95?= =?UTF-8?q?=E4=BD=8D=E6=8D=A2=E7=AE=97=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 字段库(本次新增的表与接口): - `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。 --- backend/app/agent/tools_impl.py | 85 ++- backend/app/api/combos.py | 4 +- backend/app/api/condition_fields.py | 177 +++++ backend/app/api/deps.py | 9 + backend/app/api/factors.py | 165 +++- backend/app/api/jobs.py | 4 +- backend/app/api/router.py | 2 + backend/app/api/strategies.py | 16 +- .../app/application/services/combo_service.py | 9 + .../services/condition_field_catalog.py | 198 +++++ .../application/services/factor_catalog.py | 144 +++- backend/app/domain/entities/combo.py | 12 +- .../app/domain/entities/condition_field.py | 54 ++ backend/app/domain/entities/factor.py | 64 +- backend/app/domain/entities/research.py | 4 + backend/app/domain/entities/strategy.py | 19 +- .../domain/repositories/condition_field.py | 29 + .../a7c1e4b90f21_factor_definition_params.py | 48 ++ ...e7f8a9b0_refresh_selection_descriptions.py | 155 ++++ .../d6e7f8a9b0c1_condition_field_table.py | 45 ++ .../persistence/sqlalchemy/models/__init__.py | 9 +- .../sqlalchemy/models/condition_field.py | 31 + .../persistence/sqlalchemy/models/factor.py | 13 +- .../persistence/sqlalchemy/models/strategy.py | 3 +- .../repositories/condition_field_impl.py | 102 +++ .../sqlalchemy/repositories/factor_impl.py | 29 +- backend/app/quant/condition_fields.py | 375 ++++++++++ backend/app/quant/factors.py | 708 ++++++++++++++---- backend/app/quant/selection.py | 23 +- backend/app/quant/strategy_doc.py | 28 +- backend/tests/test_agent_v3_tools.py | 23 +- backend/tests/test_combo_api.py | 21 + backend/tests/test_combo_engine.py | 1 - backend/tests/test_condition_fields.py | 319 ++++++++ backend/tests/test_factor_catalog.py | 54 ++ backend/tests/test_factor_params.py | 330 ++++++++ backend/tests/test_migrations.py | 109 +++ backend/tests/test_strategies.py | 65 ++ backend/tests/test_strategy_doc.py | 38 + 39 files changed, 3280 insertions(+), 244 deletions(-) create mode 100644 backend/app/api/condition_fields.py create mode 100644 backend/app/application/services/condition_field_catalog.py create mode 100644 backend/app/domain/entities/condition_field.py create mode 100644 backend/app/domain/repositories/condition_field.py create mode 100644 backend/app/infrastructure/persistence/migrations/versions/a7c1e4b90f21_factor_definition_params.py create mode 100644 backend/app/infrastructure/persistence/migrations/versions/c5d6e7f8a9b0_refresh_selection_descriptions.py create mode 100644 backend/app/infrastructure/persistence/migrations/versions/d6e7f8a9b0c1_condition_field_table.py create mode 100644 backend/app/infrastructure/persistence/sqlalchemy/models/condition_field.py create mode 100644 backend/app/infrastructure/persistence/sqlalchemy/repositories/condition_field_impl.py create mode 100644 backend/app/quant/condition_fields.py create mode 100644 backend/tests/test_condition_fields.py create mode 100644 backend/tests/test_factor_params.py diff --git a/backend/app/agent/tools_impl.py b/backend/app/agent/tools_impl.py index a6b2c9c..39c4974 100644 --- a/backend/app/agent/tools_impl.py +++ b/backend/app/agent/tools_impl.py @@ -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: diff --git a/backend/app/api/combos.py b/backend/app/api/combos.py index 4e471d8..d6cd10d 100644 --- a/backend/app/api/combos.py +++ b/backend/app/api/combos.py @@ -17,6 +17,8 @@ POST /api/combos/run 提交临时组合(不保存)为异步 Job from __future__ import annotations +from datetime import datetime + from fastapi import APIRouter, BackgroundTasks, HTTPException from app.api.deps import ComboRepoDep, DbSession, StrategyRepoDep @@ -108,7 +110,7 @@ def _submit_combo_job( _ensure_strategies_exist(combo, strategy_repo) job = JobRecord( id=new_id("JOB"), kind="combo", status=JobStatus.QUEUED, - spec_json=combo.model_dump_json(), + spec_json=combo.model_dump_json(), created_at=datetime.now(), ) SqlAlchemyJobRepository(session).create(job) session.commit() diff --git a/backend/app/api/condition_fields.py b/backend/app/api/condition_fields.py new file mode 100644 index 0000000..c4a2675 --- /dev/null +++ b/backend/app/api/condition_fields.py @@ -0,0 +1,177 @@ +"""字段库 API:/api/condition-fields(2026-10)。 + +GET /api/condition-fields 字段库列表(首次读取自动 seed 内置字段) +GET /api/condition-fields/available 引擎支持但尚未进库的字段(「新增」可选项) +POST /api/condition-fields 新增自定义字段(必须指向引擎真能算的字段) +PUT /api/condition-fields/{name} 改中文名/含义/分组/单位/启用状态 +DELETE /api/condition-fields/{name} 删除自定义字段(内置字段只能停用) + +设计要点(AGENT.md §24 不假装支持):字段能不能算由 `quant.condition_fields` 对着引擎域 +判定;库里登记不出来的字段会被拒绝(422),否则用户会建出「永远选不出股票」的空策略。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field + +from app.api.deps import ConditionFieldRepoDep, DbSession +from app.application.services import condition_field_catalog as svc +from app.domain.entities.condition_field import ConditionField +from app.quant.condition_fields import FieldDef, get_field, unit_options + +router = APIRouter(prefix="/condition-fields", tags=["condition-fields"]) + + +class UnitOption(BaseModel): + """可选**界面单位** + 它到**基准单位**的换算系数(提交前 ×factor,回显时 ÷factor)。 + + ``factor`` 由注册表给出(如 总市值:万元=1、亿元=10000),前端据此换算。 + """ + + model_config = ConfigDict(extra="forbid") + + unit: str + factor: float + + +class ConditionFieldOut(ConditionField): + """字段库响应:目录字段 + **从注册表派生**的单位信息。 + + ``base_unit`` / ``units`` 不落库:它们是引擎口径(注册表)的投影,若存进表里就会 + 和代码漂移。``unit`` 才是库里存的那一项(当前界面单位,必须落在 ``units`` 里)。 + """ + + base_unit: str = "" + units: list[UnitOption] = Field(default_factory=list) + + @classmethod + def from_item(cls, item: ConditionField) -> ConditionFieldOut: + d = get_field(item.name) + return cls( + **item.model_dump(exclude={"ops"}), # ops 是 computed_field,不参与构造 + base_unit=(d.unit if d else item.unit), + units=[UnitOption(unit=u, factor=f) for u, f in unit_options(item.name)], + ) + + +class FieldOption(BaseModel): + """「可新增字段」的建议项:来自代码注册表,附带默认中文名与含义。""" + + model_config = ConfigDict(extra="forbid") + + name: str + label: str + description: str + kind: str + group_name: str + unit: str = "" + units: list[UnitOption] = Field(default_factory=list) + + @classmethod + def from_def(cls, d: FieldDef) -> FieldOption: + return cls( + name=d.name, + label=d.label, + description=d.description, + kind=d.kind, + group_name=d.group_name, + unit=d.unit, + units=[UnitOption(unit=u, factor=f) for u, f in d.unit_options], + ) + + +class ConditionFieldCreate(BaseModel): + """新增请求。kind 不收:类型是引擎事实,由服务端按注册表填。""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=64) + label: str = Field(default="", max_length=64) + description: str = Field(default="", max_length=500) + group_name: str = Field(default="", max_length=32) + unit: str = Field(default="", max_length=16) + enabled: bool = True + + +class ConditionFieldUpdate(BaseModel): + """编辑请求。name/kind/source 有意不可改(name 是引擎字段名,改了就换字段了)。""" + + model_config = ConfigDict(extra="forbid") + + label: str | None = Field(default=None, max_length=64) + description: str | None = Field(default=None, max_length=500) + group_name: str | None = Field(default=None, max_length=32) + unit: str | None = Field(default=None, max_length=16) + enabled: bool | None = None + + +@router.get("", response_model=list[ConditionFieldOut], summary="字段库列表") +def list_condition_fields( + repo: ConditionFieldRepoDep, session: DbSession, include_disabled: bool = True +) -> list[ConditionFieldOut]: + """读字段库;缺失的内置字段当场补齐(幂等,稳态零写入)。 + + `include_disabled=false` 供条件编辑器使用(只列启用项);字段库管理页用默认值 + (列出全部,含停用项,否则用户没法把停用的字段再打开)。 + """ + return [ConditionFieldOut.from_item(f) for f in svc.list_fields(repo, session, include_disabled=include_disabled)] + + +@router.get("/available", response_model=list[FieldOption], summary="可新增的字段") +def list_available_fields(repo: ConditionFieldRepoDep, session: DbSession) -> list[FieldOption]: + return [FieldOption.from_def(d) for d in svc.list_available(repo, session)] + + +@router.post("", response_model=ConditionFieldOut, summary="新增自定义字段") +def create_condition_field( + body: ConditionFieldCreate, repo: ConditionFieldRepoDep, session: DbSession +) -> ConditionFieldOut: + try: + saved = svc.create_field( + repo, + session, + name=body.name, + label=body.label, + description=body.description, + group_name=body.group_name, + unit=body.unit, + enabled=body.enabled, + ) + except ValueError as exc: + # 422:语义是「引擎算不出来 / 已在库里 / 单位不在可选范围」,属于请求内容不可处理 + raise HTTPException(status_code=422, detail=str(exc)) from exc + return ConditionFieldOut.from_item(saved) + + +@router.put("/{name}", response_model=ConditionFieldOut, summary="编辑字段(中文名/含义/单位/启用)") +def update_condition_field( + name: str, body: ConditionFieldUpdate, repo: ConditionFieldRepoDep, session: DbSession +) -> ConditionFieldOut: + try: + saved = svc.update_field( + repo, + session, + name, + label=body.label, + description=body.description, + group_name=body.group_name, + unit=body.unit, + enabled=body.enabled, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"字段 {name} 不存在") from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return ConditionFieldOut.from_item(saved) + + +@router.delete("/{name}", summary="删除自定义字段(内置只能停用)") +def delete_condition_field(name: str, repo: ConditionFieldRepoDep, session: DbSession) -> dict: + try: + svc.delete_field(repo, session, name) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"字段 {name} 不存在") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"deleted": name} \ No newline at end of file diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 03f3faf..668001d 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -16,6 +16,7 @@ from app.application.services.selection_service import SelectionService from app.application.services.signal_service import SignalService from app.domain.repositories.combo import ComboRepository, GlobalConfigRepository from app.domain.repositories.composite import CompositeRepository +from app.domain.repositories.condition_field import ConditionFieldRepository from app.domain.repositories.factor import FactorRepository from app.domain.repositories.index import IndexConstituentRepository from app.domain.repositories.jobs import ExperimentRepository, JobRepository @@ -37,6 +38,9 @@ from app.infrastructure.persistence.sqlalchemy.repositories.combo_impl import ( from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import ( SqlAlchemyCompositeRepository, ) +from app.infrastructure.persistence.sqlalchemy.repositories.condition_field_impl import ( + SqlAlchemyConditionFieldRepository, +) from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import ( SqlAlchemyFactorRepository, ) @@ -174,6 +178,10 @@ def _factor_repo_factory(session: DbSession) -> FactorRepository: return SqlAlchemyFactorRepository(session) +def _condition_field_repo_factory(session: DbSession) -> ConditionFieldRepository: + return SqlAlchemyConditionFieldRepository(session) + + def _composite_repo_factory(session: DbSession) -> CompositeRepository: return SqlAlchemyCompositeRepository(session) @@ -201,6 +209,7 @@ ResearchServiceDep = Annotated[ResearchService, Depends(_service_factory)] SelectionServiceDep = Annotated[SelectionService, Depends(_selection_service_factory)] SelectionRepoDep = Annotated[SelectionRepository, Depends(_selection_repo_factory)] FactorRepoDep = Annotated[FactorRepository, Depends(_factor_repo_factory)] +ConditionFieldRepoDep = Annotated[ConditionFieldRepository, Depends(_condition_field_repo_factory)] CompositeRepoDep = Annotated[CompositeRepository, Depends(_composite_repo_factory)] SignalRepoDep = Annotated[SignalRepository, Depends(_signal_repo_factory)] SignalServiceDep = Annotated[SignalService, Depends(_signal_service_factory)] diff --git a/backend/app/api/factors.py b/backend/app/api/factors.py index a9c01e6..b3ae705 100644 --- a/backend/app/api/factors.py +++ b/backend/app/api/factors.py @@ -1,34 +1,157 @@ -"""因子目录 API:/api/factors(M7.1 起读 DB factor_definition)。 +"""因子目录 API:/api/factors(M7.1 起读 DB,2026-10 支持参数化实例)。 -目录为空时自动从代码注册表 seed(幂等);随后可登记自定义因子元数据。 -响应为 FactorDefinition 实体(含 requires 列表等)。 +## 目录的三条规则(详见 application/services/factor_catalog.py) + +① 注册表有、库里没有 → 补齐(历史 bug:表非空后新因子永远进不了目录)。 +② 能算出来的行,口径字段按代码改回 —— 目录不允许与引擎口径不一致(手改会被纠正)。 +③ 库里多出来的行保留(只补不删),标 `resolvable` 告知是否算得出来。 + +## 参数化(本文件新增的部分) + +- **暴露**:每个因子返回 `template` / `params` / `param_specs`(可编辑参数与允许范围)/ + `label`(中文名含参数)/ `source` / `resolvable` / `enabled`,界面据此渲染参数表与表单。 +- **新建**:`POST /api/factors` 传 `{template, params}` → 生成参数化实例 + `momentum(window=90,direction=higher_is_better)`。参数写在名字里,所以它**冻结**了自己的 + 口径:以后无论谁再改参数,既有策略/归档按各自名字里的参数计算,不会变义。 +- **停用**:`PATCH /api/factors` 传 `{name, enabled}`。名字里有括号/等号/逗号,放路径里 + 会被各种代理折腾,所以放在 body 里。 +- 越界参数、未知模板、重复的参数组合 → **422**,并在 detail 里说明允许范围。 """ from __future__ import annotations -from fastapi import APIRouter +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field from app.api.deps import DbSession, FactorRepoDep -from app.application.services.factor_catalog import seed_registry_factors -from app.domain.entities.factor import FactorDefinition -from app.quant.factors import list_factors +from app.application.services.factor_catalog import ( + create_parameterized_factor, + set_factor_enabled, + sync_registry_factors, +) +from app.domain.entities.factor import FactorDefinition, FactorParam +from app.quant.factors import FactorError, list_templates, resolve_factor router = APIRouter(prefix="/factors", tags=["factors"]) -@router.get("", summary="因子目录(含元数据,来自 factor_definition 表)") -def list_factor_catalog(factor_repo: FactorRepoDep, session: DbSession) -> list[FactorDefinition]: - """读目录;**缺失的注册表因子当场补齐**(幂等),稳态下零写入。 +class FactorTemplateOut(BaseModel): + """模板(算法家族):可编辑参数 + 默认值 + 口径说明,供「新建参数化因子」表单。""" - 历史 bug:原先只在「表为空」时 seed,于是表非空后**代码里新增的因子永远进不了目录**—— - 实测表内 9 条、注册表 11 条,`dividend_yield` / `dividend_yield_ttm` 长期缺失, - 前端因子下拉选不到「股息率」、归档页也查不到它的方向与含义(违反 §7 不静默)。 - 现在按「注册表有、库里没有」的差集触发 upsert:既保证目录与可计算因子一致, - 又不会覆盖用户登记的自定义因子元数据(只补不删)。 + name: str + label: str + description: str + formula: str + brief: str + requires: list[str] = Field(default_factory=list) + frequency: str = "daily" + direction_default: str = "higher_is_better" + param_specs: list[FactorParam] = Field(default_factory=list) + defaults: dict[str, Any] = Field(default_factory=dict) + instances: list[str] = Field(default_factory=list) # 该模板已有的内置实例名 + + +class FactorCreate(BaseModel): + """新建参数化因子:模板 + 参数(缺省项取模板默认值)。""" + + template: str + params: dict[str, Any] = Field(default_factory=dict) + + +class FactorPatch(BaseModel): + """开关因子(name 放 body:名字里有括号/等号/逗号,不适合放路径)。""" + + name: str + enabled: bool + + +def _template_out(tpl) -> FactorTemplateOut: + return FactorTemplateOut( + name=tpl.name, + label=tpl.label, + description=tpl.description, + formula=tpl.formula, + brief=tpl.brief, + requires=list(tpl.requires), + frequency=tpl.frequency, + direction_default=tpl.direction_default, + param_specs=[FactorParam.from_spec(s) for s in tpl.specs()], + defaults=tpl.defaults(), + instances=[name for name, _params in tpl.instances], + ) + + +def _enrich(row: FactorDefinition) -> FactorDefinition: + """把库里的行补成「引擎口径的投影」:能算出来的行一律以引擎为准。 + + - 算得出来 → description/formula/brief/frequency/lookback/direction/requires/ + template/params/param_specs/label/source 全部取自引擎(目录永不撒谎); + `enabled` 仍是库里的人配值。 + - 算不出来(历史手工登记行)→ 原样返回并标 `resolvable=False`,界面显示为不可用。 """ - existing = factor_repo.list() - missing = {d.name for d in list_factors()} - {f.name for f in existing} - if not existing or missing: - seed_registry_factors(factor_repo, session) - existing = factor_repo.list() - return existing + try: + defn, _fn = resolve_factor(row.name) + except FactorError: + return row.model_copy(update={"resolvable": False, "label": row.name}) + return FactorDefinition.from_factor_def(defn, enabled=row.enabled).model_copy( + update={"created_at": row.created_at, "version": row.version} + ) + + +@router.get("", summary="因子目录(注册表投影 + 参数化实例,含可编辑参数)") +def list_factor_catalog(factor_repo: FactorRepoDep, session: DbSession) -> list[FactorDefinition]: + """读目录(先做幂等同步,稳态零写入),每行补上参数化视图。 + + 前端据此渲染:因子名 / 中文名含参数 / 窗口 · 方向等真实参数 / 是否可当过滤条件。 + """ + sync_registry_factors(factor_repo, session) + return [_enrich(row) for row in factor_repo.list()] + + +@router.get("/templates", summary="因子模板:可编辑参数与允许范围") +def list_factor_templates() -> list[FactorTemplateOut]: + """全部模板(动量 / 波动率 / 量比 / 乖离 / 反转 / 接近新高 / 股息率…)。 + + 每个模板给出 `param_specs`(参数名、类型、允许范围/枚举、默认值、说明)—— + 界面据此渲染受控表单:**参数只在给定范围内选/填**,越界在 API 层就被拒。 + """ + return [_template_out(tpl) for tpl in list_templates()] + + +@router.post("", status_code=201, summary="新建参数化因子(模板 + 参数)") +def create_factor( + payload: FactorCreate, + factor_repo: FactorRepoDep, + session: DbSession, +) -> FactorDefinition: + """从模板派生一个新的参数化因子实例;参数写进名字,因此口径被冻结。 + + 重复的参数组合不会重复创建(409 语义由 422 承载并给出已有名字,前端直接提示即可)。 + """ + try: + row = create_parameterized_factor( + factor_repo, session, template=payload.template, params=payload.params + ) + except FactorError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _enrich(row) + + +@router.patch("", summary="启用/停用因子(只影响能否被选中)") +def patch_factor( + payload: FactorPatch, + factor_repo: FactorRepoDep, + session: DbSession, +) -> FactorDefinition: + """停用只把因子从下拉里拿掉:既有策略/归档仍按名字解析(历史不变义)。""" + try: + row = set_factor_enabled(factor_repo, session, name=payload.name, enabled=payload.enabled) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _enrich(row) diff --git a/backend/app/api/jobs.py b/backend/app/api/jobs.py index fe8632c..630bb76 100644 --- a/backend/app/api/jobs.py +++ b/backend/app/api/jobs.py @@ -42,7 +42,9 @@ def _decode_result(kind: str, result_json: str | None): return None if kind == "selection": return SelectionResult.model_validate_json(result_json) - model = BacktestResult if kind == "backtest" else FactorTestReport + # combo 回测的归档 kind 记为 "backtest",但 Job.kind 仍是 "combo" —— + # 其结果同样是 BacktestResult,按 backtest 解码(否则会被当成因子测试而校验失败)。 + model = BacktestResult if kind in ("backtest", "combo") else FactorTestReport return model.model_validate_json(result_json) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index fd2610d..2c8c464 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -13,6 +13,7 @@ from app.api import ( charts, combos, composites, + condition_fields, config, experiments, factors, @@ -30,6 +31,7 @@ api_router = APIRouter() api_router.include_router(health.router) api_router.include_router(stocks.router) api_router.include_router(factors.router) +api_router.include_router(condition_fields.router) api_router.include_router(composites.router) api_router.include_router(research.router) api_router.include_router(selections.router) diff --git a/backend/app/api/strategies.py b/backend/app/api/strategies.py index faf63fa..e363e30 100644 --- a/backend/app/api/strategies.py +++ b/backend/app/api/strategies.py @@ -4,13 +4,20 @@ 回测改由「回测组合」(/api/combos)驱动,故旧的 /{id}/expand(→ResearchSpec)已移除。 POST /api/strategies 保存策略(name 唯一;description 为空时自动补全) -POST /api/strategies/describe body: ResearchSpec → StrategyDoc(未保存的策略也能预览) +POST /api/strategies/describe body: ResearchSpec → StrategyDoc(见下方说明,非策略库路径) GET /api/strategies 列表 GET /api/strategies/{id} PUT /api/strategies/{id} 原地更新(不新建、不刷新 created_at) DELETE /api/strategies/{id} GET /api/strategies/{id}/describe → StrategyDoc +关于两个 describe 端点(不是历史遗留,各有明确用途,别合并): +- `GET /{id}/describe` → 入参是已保存的 **SelectionStrategy**(策略库「看说明/公式」用); +- `POST /describe` → 入参是 **ResearchSpec**,**给归档页**用:`/experiments/{id}` 要按当时 + 归档的旧 ResearchSpec 快照(单策略回测路径,含 selection/rebalance/costs)复述口径。 + 该路径仍然存在(`POST /api/backtests` 是底层 escape hatch),所以这里必须继续支持。 + 回测页本身已不再调用它(组合回测走 ComboRunSpec + 归档页组合卡片)。 + 路由顺序注意:`/describe` 这类**字面量路径**一律声明在 `/{strategy_id}` 之前 —— 否则会被路径参数吞掉(AGENT.md §17 的既有教训,/api/stocks/names 同源问题)。 """ @@ -71,10 +78,11 @@ def create_strategy( @router.post("/describe", response_model=StrategyDoc, summary="按 ResearchSpec 生成策略说明与公式") def describe_research_spec(spec: ResearchSpec) -> StrategyDoc: - """回测页参数即时预览用:**未保存的策略**(只有 spec)也能生成说明/公式。 + """按 **ResearchSpec** 生成说明/公式 —— 服务于归档页,不是策略库路径。 - 纯函数实现(app.quant.strategy_doc),无 IO/DB,因此不会因保存状态而失败。 - 路径与 `POST /api/strategies` 不冲突(字面量 /describe 优先于路径参数声明)。 + 调用方是 `/experiments/{id}`:它按归档里冻结的 ResearchSpec 快照(单策略回测)复述 + 「选股条件 + 交易执行依据」。纯函数实现(app.quant.strategy_doc),无 IO/DB,因此 + 不依赖任何保存状态,历史归档随时可复述。策略库自身的说明走 `GET /{id}/describe`。 """ return describe_strategy(spec) diff --git a/backend/app/application/services/combo_service.py b/backend/app/application/services/combo_service.py index a20e4fd..9864a75 100644 --- a/backend/app/application/services/combo_service.py +++ b/backend/app/application/services/combo_service.py @@ -78,6 +78,15 @@ class ComboService: daily=daily, eligibility_fns=eligibility_fns, ) + # 把价格口径写进 config_snapshot(与 ResearchService._annotate_price_basis 同口径), + # 否则归档页/结果头读不到 adjust_mode,会误显示「不复权」(组合实际用的是公共配置的复权)。 + # 注意:不能覆盖整个 config_snapshot —— 引擎已把 ComboRunSpec 固化在里面(可复现依据)。 + mode = config.price_adjustment + result.config_snapshot["price_basis"] = { + "adjust_mode": mode, + "price_basis": "adjust_factor" if mode != "none" else "raw_close", + "execution_price_basis": "close_adj" if mode != "none" else "close_raw", + } _stage(on_stage, "analysis") return _fill_names(result, self._last_stocks) diff --git a/backend/app/application/services/condition_field_catalog.py b/backend/app/application/services/condition_field_catalog.py new file mode 100644 index 0000000..ac26a01 --- /dev/null +++ b/backend/app/application/services/condition_field_catalog.py @@ -0,0 +1,198 @@ +"""字段库用例:目录 seed + 校验 + 增删改(2026-10)。 + +与 factor_catalog 同一套规矩:**DB 是目录契约源,代码注册表是可用性的唯一事实来源**。 +读取时把「注册表有、库里没有」的内置字段补进去(只补不删、不覆盖用户改过的文案)。 + +单位的两层含义(2026-10 补)见 `quant/condition_fields.py`:``unit`` 存的是**界面单位** +(输入/显示用,可从注册表给的阶梯里选),引擎始终按**基准单位**存储与比较, +换算是提交/回显时按系数做的 —— 所以改单位不会让任何历史策略变义。 +""" + +from __future__ import annotations + +from app.domain.entities.condition_field import ConditionField +from app.domain.repositories.condition_field import ConditionFieldRepository +from app.quant.condition_fields import ( + GROUP_ORDER, + FieldDef, + available_fields, + curated_fields, + get_field, + reason_unsupported, + unit_allowed, + unit_options, +) + +_SORT_BASE = 100 + + +def _check_unit(name: str, unit: str) -> str: + """界面单位必须落在注册表给的阶梯里(不允许自由文本 —— 见 AGENT §24)。 + + 空串 = 保持基准单位。给出可用单位清单,用户不用猜为什么被拒。 + """ + d = get_field(name) + base = d.unit if d else "" + if not unit.strip(): + return base + if not unit_allowed(name, unit.strip()): + allowed = "、".join(u for u, _ in unit_options(name)) or "(无可用单位)" + raise ValueError( + f"字段「{name}」不支持单位「{unit.strip()}」:可选 {allowed}。" + "单位只能从这些里选,因为换算是按固定系数做的(引擎按基准单位比较)。" + ) + return unit.strip() + + +def _sort_order(d: FieldDef) -> int: + """同分组内保持注册表顺序(分组顺序 × 1000 + 组内序号)。""" + try: + g = GROUP_ORDER.index(d.group_name) + except ValueError: + g = len(GROUP_ORDER) + return g * 1000 + _SORT_BASE + + +def _from_def(d: FieldDef) -> ConditionField: + return ConditionField( + name=d.name, + label=d.label, + description=d.description, + kind=d.kind, + group_name=d.group_name, + unit=d.unit, + source="builtin", + enabled=True, + sort_order=_sort_order(d), + ) + + +def sync_builtin_fields(repo: ConditionFieldRepository, session) -> int: + """补齐缺失的**默认内置字段**(幂等;已存在的一律不动,用户改过的文案得以保留)。 + + 只 seed `curated=True` 的那批:`curated=False` 的字段是「引擎支持但默认不进库」的 + 选项,留给用户在字段库里按需新增(见 `list_available`)—— 否则「新增字段」永远 + 无字段可选。 + """ + existing = {f.name for f in repo.list()} + missing = [_from_def(d) for d in curated_fields() if d.name not in existing] + added = repo.insert_missing(missing) + if added: + session.commit() + return added + + +def list_fields(repo: ConditionFieldRepository, session, include_disabled: bool = True): + """字段库列表(首次读取自动 seed)。按分组/注册表顺序排序。""" + items = repo.list() + if not items: + sync_builtin_fields(repo, session) + items = repo.list() + # 代码里新注册的默认字段也要补上:按差集触发,稳态零写入 + if {d.name for d in curated_fields()} - {f.name for f in items}: + sync_builtin_fields(repo, session) + items = repo.list() + if not include_disabled: + items = [i for i in items if i.enabled] + return items + + +def list_available(repo: ConditionFieldRepository, session): + """引擎支持但尚未进目录的字段(「新增字段」的可选项)。""" + return available_fields({f.name for f in repo.list()}) + + +def create_field( + repo: ConditionFieldRepository, + session, + *, + name: str, + label: str = "", + description: str = "", + group_name: str = "", + unit: str = "", + enabled: bool = True, +) -> ConditionField: + """新增自定义字段。 + + 校验顺序有意如此:先看引擎能不能算(不能算就 422,绝不放行)→ 再看是否已存在。 + 这样用户拿到的是「这个字段引擎算不出来」而不是含糊的「已存在」。 + """ + key = name.strip() + reason = reason_unsupported(key) + if reason: + raise ValueError(reason) + if repo.get(key) is not None: + raise ValueError(f"字段「{key}」已在字段库中:直接编辑它,或给它改个中文名/含义即可") + d = get_field(key) + assert d is not None # reason_unsupported 为空 ⇒ 注册表必有此字段 + chosen = _check_unit(key, unit) + item = ConditionField( + name=key, + label=(label.strip() or d.label), + description=(description.strip() or d.description), + kind=d.kind, # 类型来自引擎,不接受调用方声明 + group_name=(group_name.strip() or d.group_name), + unit=chosen, + source="custom", + enabled=enabled, + sort_order=_sort_order(d), + ) + saved = repo.save(item) + session.commit() + return saved + + +def update_field( + repo: ConditionFieldRepository, + session, + name: str, + *, + label: str | None = None, + description: str | None = None, + group_name: str | None = None, + unit: str | None = None, + enabled: bool | None = None, +) -> ConditionField: + """编辑字段文案/分组/**界面单位**/启用状态。 + + ``name`` / ``kind`` / ``source`` 不可改:前者是引擎字段名,后两者是引擎事实与 + 条目来历 —— 允许改会让「字段库」与引擎脱节(§24 不做假支持)。 + ``unit`` 可改但**只能在注册表给的阶梯里选**(如 万元 ⇄ 亿元):它是界面单位, + 提交/回显按固定系数换算,引擎始终用基准单位 —— 所以改它不会让历史策略变义。 + """ + item = repo.get(name) + if item is None: + raise KeyError(name) + patch = item.model_copy( + update={ + k: v + for k, v in { + "label": label, + "description": description, + "group_name": group_name, + "unit": _check_unit(name, unit) if unit is not None else None, + "enabled": enabled, + }.items() + if v is not None + } + ) + if not patch.label.strip(): + raise ValueError("中文名不能为空(下拉里要显示它)") + saved = repo.save(patch) + session.commit() + return saved + + +def delete_field(repo: ConditionFieldRepository, session, name: str) -> None: + """删除自定义字段。内置字段不允许删除 —— 删了下次 seed 又会补回来,只会让人困惑。""" + item = repo.get(name) + if item is None: + raise KeyError(name) + if item.source == "builtin": + raise ValueError( + f"「{name}」是内置字段,不能删除(删掉下次读取也会自动补回)。" + "如果不想在条件里看到它,请改为「停用」。" + ) + repo.delete(name) + session.commit() \ No newline at end of file diff --git a/backend/app/application/services/factor_catalog.py b/backend/app/application/services/factor_catalog.py index a71a88f..4a6baa0 100644 --- a/backend/app/application/services/factor_catalog.py +++ b/backend/app/application/services/factor_catalog.py @@ -1,21 +1,147 @@ -"""因子目录用例:把代码注册表因子 seed 进 DB(M7.1)。 +"""因子目录用例:注册表投影 + 参数化实例的新建/停用(M7.1 起,2026-10 参数化)。 -DB 为目录契约源;本服务在 /api/factors 首次读取为空时自动 seed(幂等), -后续代码新增因子也通过同一入口同步,保持目录与可计算因子一致。 +## 目录与代码的分工(这是本模块的核心规矩) + +- **能不能算** = 代码注册表(`quant/factors.py`)唯一决定。库里的行只要能解析出 + `(模板, 参数)` 就算得出来;解析不出来的行(历史手工登记)保留但在目录里标 + `resolvable=False`,引用时抛 `FactorError`(不假装支持)。 +- **有哪些因子** = 目录(DB)。内置实例由代码投影进来;**参数化实例**(如 + `momentum(window=90,direction=higher_is_better)`)由人在目录里创建 —— 参数写在名字里, + 所以「同一个模板的多个参数版本」天然并存,且任何一个都冻结了自己的口径。 +- **口径文案**(description/formula/brief/frequency/lookback/direction/requires)永远按 + 代码收敛:只要这行算得出来,它的文案就是引擎的文案。手改会被改回 —— 因为 + 「文档写一套、代码跑另一套」是本仓库明令禁止的;要改口径就改代码。 +- **唯一人配的字段**是 `enabled`(是否出现在因子下拉里):只对参数化实例生效; + 内置实例的开关同样由代码收敛(恒 True)。停用不影响已引用它的策略/归档解析 —— + 历史口径不能被一个开关改义。 + +稳态(目录 == 代码投影)零写入,不会每次 GET 都刷库。 """ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from app.domain.entities.factor import FactorDefinition from app.domain.repositories.factor import FactorRepository -from app.quant.factors import list_factors +from app.quant.factors import ( + FactorError, + build_factor_def, + canonical_key, + get_template, + is_resolvable, + list_factors, + resolve_factor, +) + +# 「引擎口径」字段:一旦与代码不一致,目录就在撒谎,必须按代码改回。 +# enabled 也在其中 —— 但它只对**注册表实例**收敛(见 sync_registry_factors)。 +REGISTRY_FIELDS = ( + "description", + "formula", + "brief", + "frequency", + "lookback", + "direction", + "requires", + "enabled", +) + +# 参数化实例同样要收敛的字段(不含 enabled:开关是人配的)。 +_ENGINE_FIELDS = tuple(f for f in REGISTRY_FIELDS if f != "enabled") -def seed_registry_factors(repo: FactorRepository, session) -> int: - """把 quant/factors 注册表的元数据 upsert 进 factor_definition(幂等)。""" - defs = [FactorDefinition.from_registry_def(d) for d in list_factors()] - if not defs: +def _registry_names() -> set[str]: + return {d.name for d in list_factors()} + + +def _wanted_rows(current: list[FactorDefinition]) -> list[FactorDefinition]: + """应然状态:内置实例按代码;能解析出来的参数化实例口径也按代码(开关保留)。""" + registry = _registry_names() + wanted = [FactorDefinition.from_registry_def(d) for d in list_factors()] + for row in current: + if row.name in registry: + continue # 内置实例已由上面的投影覆盖 + try: + defn, _fn = resolve_factor(row.name) + except FactorError: + continue # 算不出来的手登记行:不碰(只有人写的备注,没有可收敛的引擎口径) + want = FactorDefinition.from_factor_def(defn, enabled=row.enabled) + want = want.model_copy(update={"created_at": row.created_at, "version": row.version}) + wanted.append(want) + return wanted + + +def _differs(current: FactorDefinition | None, want: FactorDefinition, fields) -> bool: + if current is None: + return True + return any(getattr(current, name) != getattr(want, name) for name in fields) + + +def sync_registry_factors(repo: FactorRepository, session) -> int: + """把代码投影进目录(幂等);返回本次写入的行数,稳态为 0。""" + current = repo.list() + by_name = {f.name: f for f in current} + registry = _registry_names() + stale: list[FactorDefinition] = [] + for want in _wanted_rows(current): + row = by_name.get(want.name) + fields = REGISTRY_FIELDS if want.name in registry else _ENGINE_FIELDS + if row is None or _differs(row, want, fields): + stale.append(want) + if not stale: return 0 - n = repo.upsert_many(defs) + n = repo.upsert_many(stale) session.commit() return n + + +def create_parameterized_factor( + repo: FactorRepository, + session, + *, + template: str, + params: Mapping[str, Any] | None = None, +) -> FactorDefinition: + """从模板 + 参数创建一个**新的参数化因子实例**(参数写在名字里,冻结口径)。 + + 参数缺省项取模板默认值;越界/未知参数/重复的参数组合一律报错(不静默纠正)。 + """ + tpl = get_template(template) # 未知模板 → FactorError + key = canonical_key(tpl, params or {}) # 越界/未知参数 → FactorError + if repo.get(key) is not None: + raise ValueError(f"该参数组合的因子已存在:{key}(参数相同不会重复创建)") + defn = build_factor_def(tpl, params or {}, name=key, source="custom") + entity = FactorDefinition.from_factor_def(defn, enabled=True) + repo.upsert_many([entity]) + session.commit() + return entity + + +def set_factor_enabled( + repo: FactorRepository, + session, + *, + name: str, + enabled: bool, +) -> FactorDefinition: + """启用/停用目录里的因子(只影响「能不能被选中」,不影响历史解析)。""" + if name in _registry_names(): + raise ValueError( + f"「{name}」是代码注册表里的内置因子,开关由代码决定,不能在目录里停用;" + "如果要一个不同参数的版本,请从模板新建参数化因子。" + ) + row = repo.get(name) + if row is None: + raise LookupError(f"因子 {name} 不在目录里") + if not is_resolvable(name): + raise ValueError(f"因子 {name} 引擎算不出来(未注册模板/参数非法),不能启用或停用") + updated = row.model_copy(update={"enabled": enabled}) + repo.upsert_many([updated]) + session.commit() + return updated + + +# 兼容旧名(语义相同:把注册表同步进目录)。 +seed_registry_factors = sync_registry_factors \ No newline at end of file diff --git a/backend/app/domain/entities/combo.py b/backend/app/domain/entities/combo.py index 8e93e9f..f5d6dbf 100644 --- a/backend/app/domain/entities/combo.py +++ b/backend/app/domain/entities/combo.py @@ -18,7 +18,7 @@ from __future__ import annotations from datetime import date, datetime -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.domain.entities.research import CostSpec @@ -31,8 +31,13 @@ class GlobalConfig(BaseModel): 为什么把复权口径也放这里:一次回测只能有一个复权口径(同一份行情不能既前复权 又后复权),而多个选股策略可能想混用 —— 与其让它们在组合里打架,不如统一为 全局口径,高股息默认 hfq。若将来确需按组合区分,再加字段即可(向前兼容)。 + + `extra="forbid"`:PUT /api/config 若带未知字段(拼错键名、旧版遗留键)直接报错, + 避免「以为改了某项、其实被静默忽略」。 """ + model_config = ConfigDict(extra="forbid") + id: str = Field(default="default", description="单例主键,恒为 default") commission_rate: float = Field(default=0.0003, ge=0, le=0.01, description="佣金率(如 0.0003 = 万三)") stamp_tax_rate: float = Field(default=0.0005, ge=0, le=0.01, description="印花税率(仅卖出)") @@ -72,8 +77,13 @@ class BacktestCombo(BaseModel): - `hold_max_days` = Tmax:个股**最多**持有天数 —— 超过即强制了结(None = 不限)。 - `rebalance_freq`:多久重新打分排序并调仓一次(日/周/月)。 ⚠️ Tmax 强制卖出**每个交易日**都检查(不只调仓日),否则月频下会远超 Tmax。 + + `extra="forbid"`:回测参数写错键名(如 hold_days、capital)时报错而非静默用默认值 —— + 静默用默认值会让「我明明设了 30 天」变成「其实没生效」,是本项目明确禁止的降级方式。 """ + model_config = ConfigDict(extra="forbid") + id: str = "" name: str = Field(min_length=1, max_length=64) description: str = "" diff --git a/backend/app/domain/entities/condition_field.py b/backend/app/domain/entities/condition_field.py new file mode 100644 index 0000000..d7956d0 --- /dev/null +++ b/backend/app/domain/entities/condition_field.py @@ -0,0 +1,54 @@ +"""字段库领域实体(2026-10:过滤条件字段目录 DB 化)。 + +与因子目录(``FactorDefinition``)同一套思路: + +- **DB 是字段库的契约源**:中文名、含义、单位、是否启用、自定义条目都入库; +- **引擎是字段可用性的唯一事实来源**:字段能不能算由 ``quant.condition_fields`` + 对着引擎域校验,登记不出来的字段一律拒绝(防「建出来永远选不出股票」的伪字段)。 + +可编辑边界(有意为之): +- ``name`` 是引擎字段名,**不可改**(改了就指向另一个字段,等于换字段); +- ``kind`` 由引擎类型决定,**不可改**(字符串字段不能比大小); +- ``label`` / ``description`` / ``group_name`` / ``unit`` / ``enabled`` 可编辑 —— + 内置字段也允许改文案(seed「只补不删」,不会覆盖用户的措辞)。 +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, computed_field + +FIELD_KINDS: tuple[str, ...] = ("num", "str") +FIELD_SOURCES: tuple[str, ...] = ("builtin", "custom") + +# 类型 → 可用比较符(单一事实来源)。 +# 字符串字段只能等值/集合:引擎 _compare 对字符串的 >/≥/ 5」这类选项摆出来,用户点出来的就是永远为假的条件。 +OPS_NUM: tuple[str, ...] = ("gt", "gte", "lt", "lte", "eq", "ne") +OPS_STR: tuple[str, ...] = ("eq", "ne", "in", "not_in") +OPS_BY_KIND: dict[str, tuple[str, ...]] = {"num": OPS_NUM, "str": OPS_STR} + + +class ConditionField(BaseModel): + """字段库中的一个条件字段。""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=64, description="引擎字段名,如 dv_ratio / static.industry") + label: str = Field(default="", max_length=64, description="中文名(下拉里展示)") + description: str = Field(default="", max_length=500, description="含义 / 口径(含单位)") + kind: str = Field(default="num", pattern="^(num|str)$") + group_name: str = Field(default="行情", max_length=32) + unit: str = Field(default="", max_length=16) + source: str = Field(default="builtin", pattern="^(builtin|custom)$") + enabled: bool = Field(default=True, description="False=从选择器隐藏(内置不可删除,只能停用)") + sort_order: int = 100 + created_at: datetime | None = None + updated_at: datetime | None = None + + @computed_field # type: ignore[prop-decorator] + @property + def ops(self) -> list[str]: + """该字段可用的比较符(前端据此收窄下拉,不自己猜)。""" + return list(OPS_BY_KIND.get(self.kind, OPS_NUM)) \ No newline at end of file diff --git a/backend/app/domain/entities/factor.py b/backend/app/domain/entities/factor.py index 9c760d4..9a97eed 100644 --- a/backend/app/domain/entities/factor.py +++ b/backend/app/domain/entities/factor.py @@ -1,19 +1,51 @@ -"""因子目录领域实体(M7.1:因子元数据 DB 化,v2 §11)。 +"""因子目录领域实体(M7.1:因子元数据 DB 化,v2 §11;2026-10 参数化)。 -DB 是因子目录的契约源:元数据(含自定义因子登记)入库; -计算执行仍由代码注册表(quant/factors.py)提供 —— 登记但未注册计算的因子 -在 score/condition 中引用时仍抛 FactorError(防静默伪因子)。 +DB 是因子目录的契约源:**哪些因子存在**(含用户从模板派生的参数化实例)入库; +**能不能算**仍由代码注册表(quant/factors.py)唯一决定 —— 登记但解析不出来的因子 +在 score/condition 里引用时抛 FactorError(不假装支持)。 + +参数化的读法:参数化实例的名字本身就是身份(`momentum(window=90,direction=...…)`), +所以 template / params / param_specs / label / source / resolvable 都是**由名字解析出来的 +投影**,不落库。落库的只有 `enabled`(是否出现在下拉里)—— 这是人做的配置, +不是引擎事实。好处:参数不可能出现「表里一套、键里一套」的分裂。 """ from __future__ import annotations from datetime import datetime +from typing import Any from pydantic import BaseModel, Field +class FactorParam(BaseModel): + """一个可编辑参数的约束(与 quant/factors.ParamSpec 对齐,供界面渲染表单)。""" + + name: str + label: str = "" + kind: str = "int" # "int" | "enum" + default: Any = None + minimum: int | None = None + maximum: int | None = None + choices: list[str] = Field(default_factory=list) + note: str = "" + + @classmethod + def from_spec(cls, spec) -> FactorParam: + return cls( + name=spec.name, + label=spec.label, + kind=spec.kind, + default=spec.default, + minimum=spec.minimum, + maximum=spec.maximum, + choices=list(spec.choices), + note=spec.note, + ) + + class FactorDefinition(BaseModel): - name: str = Field(min_length=1, max_length=64) + name: str = Field(min_length=1, max_length=128) description: str = "" formula: str = "" brief: str = "" @@ -23,10 +55,23 @@ class FactorDefinition(BaseModel): requires: list[str] = Field(default_factory=list) version: str = "1" created_at: datetime | None = None + # ---- 参数化(落库的只有 enabled;其余由 name 解析投影而来)---- + enabled: bool = True + template: str = "" # 模板名,如 "momentum" + params: dict[str, Any] = Field(default_factory=dict) # 冻结的参数取值 + param_specs: list[FactorParam] = Field(default_factory=list) # 可编辑参数与约束 + label: str = "" # 中文显示名(含参数) + source: str = "builtin" # builtin(代码注册表实例)| custom(目录里的参数化实例) + resolvable: bool = True # False = 登记了但引擎算不出来(历史手工登记行) @classmethod def from_registry_def(cls, d) -> FactorDefinition: """由 quant/factors.FactorDef(dataclass)构造目录实体(seed 用)。""" + return cls.from_factor_def(d, enabled=True) + + @classmethod + def from_factor_def(cls, d, *, enabled: bool = True) -> FactorDefinition: + """由因子实例(内置或参数化)构造目录实体(seed / 新建用)。""" return cls( name=d.name, description=d.description, @@ -36,4 +81,11 @@ class FactorDefinition(BaseModel): lookback=d.lookback, direction=d.direction, requires=list(d.requires), - ) + enabled=enabled, + template=d.template, + params=dict(d.params), + param_specs=[FactorParam.from_spec(s) for s in d.param_specs], + label=d.label, + source=d.source, + resolvable=True, + ) \ No newline at end of file diff --git a/backend/app/domain/entities/research.py b/backend/app/domain/entities/research.py index dcea6ae..02f629f 100644 --- a/backend/app/domain/entities/research.py +++ b/backend/app/domain/entities/research.py @@ -54,6 +54,10 @@ class ConditionSpec(BaseModel): 右操作数取 value(字面量)或 ref(另一字段名),二者二选一。 + 字段域的事实来源:`quant/condition_fields.py`(字段库注册表,含中文名与口径)。 + `/api/condition-fields`(前端下拉)、该注册表与引擎求值共用同一份定义, + 避免「前端列一个、引擎算另一个」的漂移。 + 定义位置说明:本模型被 ResearchSpec(回测)与 SelectionQuery(选股)共用, 故落在 research.py(被 selection.py 依赖的低层模块),selection.py 再 re-export, 避免循环导入。 diff --git a/backend/app/domain/entities/strategy.py b/backend/app/domain/entities/strategy.py index d0f7148..2be2f38 100644 --- a/backend/app/domain/entities/strategy.py +++ b/backend/app/domain/entities/strategy.py @@ -12,18 +12,29 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from app.domain.entities.research import ConditionSpec, FactorSpec, UniverseSpec class SelectionStrategy(BaseModel): - """一个选股策略 = 选股条件组合(不含任何回测执行参数)。""" + """一个选股策略 = 选股条件组合(不含任何回测执行参数)。 + + `extra="forbid"`(2026-09 收尾):请求里若混入旧版的回测执行参数(selection / + rebalance / costs / portfolio / initial_capital / period …),一律**报错**而不是 + 静默丢弃 —— 否则调用方会以为「在策略上设了费率/调仓」,实际服务端根本没存 + (AGENT.md 禁止静默降级与假装支持)。回测参数的正确位置是 BacktestCombo + GlobalConfig。 + 兼容性:历史行残留的旧键由仓储 `_to_entity` 在**读出前**剔除,因此不受 forbid 影响。 + """ + + model_config = ConfigDict(extra="forbid") id: str = "" name: str = Field(min_length=1, max_length=64) description: str = "" - spec_type: str = Field(default="selection", pattern="^(selection|backtest)$") + # 取值域收敛为 selection:本实体就是「选股策略」。历史 DB 列里的 "backtest" + # 不会被读出(仓储 _to_entity 丢弃该键并回落到默认值),故收紧不会破坏旧数据。 + spec_type: str = Field(default="selection", pattern="^selection$") universe: UniverseSpec = UniverseSpec() factors: list[FactorSpec] = Field(min_length=1, description="打分因子(至少 1 个)") conditions: list[ConditionSpec] = Field( @@ -33,7 +44,7 @@ class SelectionStrategy(BaseModel): created_at: datetime | None = None @model_validator(mode="after") - def _no_duplicate_factors(self) -> "SelectionStrategy": + def _no_duplicate_factors(self) -> SelectionStrategy: names = [f.name for f in self.factors] if len(set(names)) != len(names): raise ValueError("factors 存在重复因子名") diff --git a/backend/app/domain/repositories/condition_field.py b/backend/app/domain/repositories/condition_field.py new file mode 100644 index 0000000..e664f69 --- /dev/null +++ b/backend/app/domain/repositories/condition_field.py @@ -0,0 +1,29 @@ +"""字段库 Repository 协议(依赖倒置)。""" + +from __future__ import annotations + +from typing import Protocol + +from app.domain.entities.condition_field import ConditionField + + +class ConditionFieldRepository(Protocol): + def list(self) -> list[ConditionField]: + """按 sort_order, name 排序返回全部条目(含停用项)。""" + ... + + def get(self, name: str) -> ConditionField | None: ... + + def insert_missing(self, items: list[ConditionField]) -> int: + """只插入不存在的条目(seed 内置字段用)。 + + 语义上是「只补不删、不覆盖」:已存在的行**原样保留** —— 用户在字段库里 + 改过的中文名/含义不会被下次 seed 冲掉(与 factor_definition 同规矩)。 + """ + ... + + def save(self, item: ConditionField) -> ConditionField: + """新增或整体更新一条(自定义字段增改、内置字段改文案/停用)。""" + ... + + def delete(self, name: str) -> bool: ... \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/migrations/versions/a7c1e4b90f21_factor_definition_params.py b/backend/app/infrastructure/persistence/migrations/versions/a7c1e4b90f21_factor_definition_params.py new file mode 100644 index 0000000..20dba1d --- /dev/null +++ b/backend/app/infrastructure/persistence/migrations/versions/a7c1e4b90f21_factor_definition_params.py @@ -0,0 +1,48 @@ +"""factor_definition:支持参数化因子实例(2026-10) + +两处改动: +1. `name` 64 → 128:参数化实例把参数写进名字 + (`momentum(window=90,direction=higher_is_better)`),64 位不够留余量。 +2. 新增 `enabled`:唯一由人配置的字段 —— 是否出现在因子下拉/字段库里。 + 内置实例的开关注仍由代码注册表收敛;停用**不影响**已引用它的策略/归档解析, + 历史口径不能被开关改义。 + +SQLite 不支持直接改列类型,因此用 batch_alter_table(与本仓库既有迁移一致)。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a7c1e4b90f21" +down_revision: str | None = "d6e7f8a9b0c1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("factor_definition", schema=None) as batch_op: + batch_op.alter_column( + "name", + existing_type=sa.String(length=64), + type_=sa.String(length=128), + existing_nullable=False, + ) + op.add_column( + "factor_definition", + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + op.drop_column("factor_definition", "enabled") + with op.batch_alter_table("factor_definition", schema=None) as batch_op: + batch_op.alter_column( + "name", + existing_type=sa.String(length=128), + type_=sa.String(length=64), + existing_nullable=False, + ) \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/migrations/versions/c5d6e7f8a9b0_refresh_selection_descriptions.py b/backend/app/infrastructure/persistence/migrations/versions/c5d6e7f8a9b0_refresh_selection_descriptions.py new file mode 100644 index 0000000..7da119b --- /dev/null +++ b/backend/app/infrastructure/persistence/migrations/versions/c5d6e7f8a9b0_refresh_selection_descriptions.py @@ -0,0 +1,155 @@ +"""重算存量选股策略的过时 description(2026-09 重构收尾) + +Revision ID: c5d6e7f8a9b0 +Revises: b4c5d6e7f8a9 +Create Date: 2026-10-01 + +背景:b4c5d6e7f8a9 把一个策略的 config_json 里回测执行参数剥掉了,但**没有**重算 +`strategy.description`。旧描述是重构前由 describe_strategy 从「全套参数」自动生成的, +于是策略库里会出现这种自相矛盾的说明: + + 「…每 6 个月重新择股、每 6 个月调仓,后复权口径、按调仓日收盘价成交 + (含佣金 0.03%/印花税 0.05%/滑点 0.1%)。」 + +而选股策略现在**不再持有**调仓/成本/复权,这些由「回测组合 + 公共配置」在回测时决定。 +本迁移用当前口径的 describe_strategy(纯函数,无 IO/DB)重算这些陈旧说明。 + +安全性 —— 只改「可证明是旧自动生成」的行,不碰人工撰写的说明: + 1. description 为空/纯空白(API 保存契约要求必须有说明,空值必然是历史遗留)→ 补全; + 2. description 含旧自动文案独有的回测执行词(佣金/印花税/滑点/调仓/择股/复权口径/ + 收盘价成交/最低佣金/初始资金)→ 重算。新口径的说明**绝不会**出现这些词 + (见 strategy_doc._describe_selection_only),因此命中即旧自动文案。 + 其余行原样保留(kept)。无法解析/校验失败的行跳过并打印告警,绝不静默改写。 + +为什么在迁移里 import 应用代码:说明文本的唯一事实来源就是 `describe_strategy` +(AGENT.md §24:不许另写一份近似文案)。自己复制一份文案逻辑才是真正的漂移风险。 +代价是该迁移的产物依赖当时的代码版本 —— 对「一次性回填存量说明」这个用途可以接受, +且新库 upgrade 时 strategy 表为空、不受影响。 + +downgrade 仅回滚结构层面:**不恢复**被重算的旧说明(原文未备份),因此不可逆。 +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c5d6e7f8a9b0" +down_revision: str | None = "b4c5d6e7f8a9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# 与 strategy.description 列宽一致(StrategyModel.description = String(300)) +_DESCRIPTION_MAX_CHARS = 300 + +# 选股策略不再承载的键(与 b4c5d6e7f8a9 一致;历史行可能仍残留) +_LEGACY_KEYS = ( + "selection", + "rebalance", + "costs", + "portfolio", + "price_adjustment", + "selection_interval_months", + "rebalance_interval_months", +) + +# 由 DB 列承载、不应从 config_json 再喂给实体的键 +_COLUMN_KEYS = ("id", "name", "description", "spec_type", "version") + +# 旧「全套参数」自动文案独有的回测执行词 —— 新口径说明不会出现(命中即认定陈旧) +_LEGACY_MARKERS = ( + "佣金", + "印花税", + "滑点", + "调仓", + "择股", + "复权口径", + "收盘价成交", + "最低佣金", + "初始资金", +) + +_LEGACY_MARKER_RE = re.compile("|".join(_LEGACY_MARKERS)) + + +def _truncate(text: str) -> str: + """与 API 的说明补全同口径:超列宽按字符截断并显式加省略号。""" + if len(text) <= _DESCRIPTION_MAX_CHARS: + return text + return text[: _DESCRIPTION_MAX_CHARS - 1] + "…" + + +def _derive_summary(name: str, description: str, data: dict) -> str | None: + """按当前口径重算一句话说明;无法构造实体时返回 None(调用方跳过并告警)。""" + # 延迟 import:保持迁移模块导入轻量,且让 alembic env 先完成自身引导。 + from app.domain.entities.strategy import SelectionStrategy + from app.quant.strategy_doc import describe_strategy + + payload = dict(data) + for key in _COLUMN_KEYS + _LEGACY_KEYS: + payload.pop(key, None) + try: + st = SelectionStrategy(name=name, description=description, **payload) + except Exception as exc: # noqa: BLE001 —— 逐行容错:坏行跳过并告警,不阻断整次迁移 + print(f"[refresh-strategy-docs] 跳过无法解析的策略 {name!r}: {exc}", flush=True) + return None + return describe_strategy(st).summary + + +def _is_stale(name: str, description: str) -> bool: + return (not (description or "").strip()) or bool(_LEGACY_MARKER_RE.search(description or "")) + + +def upgrade() -> None: + conn = op.get_bind() + rows = conn.execute( + sa.text("SELECT id, name, description, config_json FROM strategy") + ).fetchall() + + rewritten = kept = broken = 0 + for row_id, name, description, cfg_text in rows: + try: + data = json.loads(cfg_text) if cfg_text else {} + except json.JSONDecodeError: + broken += 1 + print(f"[refresh-strategy-docs] 跳过 config_json 损坏的策略 {row_id}", flush=True) + continue + if not isinstance(data, dict): + broken += 1 + print(f"[refresh-strategy-docs] 跳过 config_json 非对象的策略 {row_id}", flush=True) + continue + if not _is_stale(name, description): + kept += 1 # 人工撰写的说明:不动它 + continue + summary = _derive_summary(name, description or "", data) + if summary is None: + broken += 1 + continue + summary = _truncate(summary) + if summary == (description or ""): + kept += 1 + continue + conn.execute( + sa.text("UPDATE strategy SET description = :desc WHERE id = :id"), + {"desc": summary, "id": row_id}, + ) + rewritten += 1 + + print( + f"[refresh-strategy-docs] 重算 {rewritten} 条陈旧/空说明," + f"保留 {kept} 条,跳过 {broken} 条异常行(共 {len(rows)} 条)", + flush=True, + ) + + +def downgrade() -> None: + # 旧说明原文未备份,无法还原:回滚只表示「结构层面无事可做」。 + # 显式空实现(而非 pass 无说明),避免读者误以为会恢复文案。 + print( + "[refresh-strategy-docs] downgrade:被重算的说明不可还原(原文未备份),不执行任何写操作", + flush=True, + ) \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/migrations/versions/d6e7f8a9b0c1_condition_field_table.py b/backend/app/infrastructure/persistence/migrations/versions/d6e7f8a9b0c1_condition_field_table.py new file mode 100644 index 0000000..eab75a5 --- /dev/null +++ b/backend/app/infrastructure/persistence/migrations/versions/d6e7f8a9b0c1_condition_field_table.py @@ -0,0 +1,45 @@ +"""condition_field 表(2026-10 字段库:过滤条件字段目录入库) + +Revision ID: d6e7f8a9b0c1 +Revises: c5d6e7f8a9b0 +Create Date: 2026-10-01 + +背景:策略库的过滤条件此前只能手填字段名(dv_ratio / static.industry …), +用户看不到含义、写错也不报错(未知字段求值恒为 None,条件永远不通过)。 +本表存放字段库目录:内置字段由 quant/condition_fields.py 注册表在 API 首次读取时 +seed(只补不删,不覆盖用户改过的文案),自定义字段与停用状态也落在本表。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d6e7f8a9b0c1" +down_revision: str | None = "c5d6e7f8a9b0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "condition_field", + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("label", sa.String(length=64), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("kind", sa.String(length=8), nullable=False), + sa.Column("group_name", sa.String(length=32), nullable=False), + sa.Column("unit", sa.String(length=16), nullable=False), + sa.Column("source", sa.String(length=8), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("sort_order", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("name"), + ) + + +def downgrade() -> None: + op.drop_table("condition_field") \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/sqlalchemy/models/__init__.py b/backend/app/infrastructure/persistence/sqlalchemy/models/__init__.py index 4a44645..642e43a 100644 --- a/backend/app/infrastructure/persistence/sqlalchemy/models/__init__.py +++ b/backend/app/infrastructure/persistence/sqlalchemy/models/__init__.py @@ -4,13 +4,16 @@ 模型统一继承 infra.persistence.sqlalchemy.base.Base。 """ -from app.infrastructure.persistence.sqlalchemy.models.composite import ( # noqa: F401 - FactorCompositeModel, -) from app.infrastructure.persistence.sqlalchemy.models.combo import ( # noqa: F401 BacktestComboModel, GlobalConfigModel, ) +from app.infrastructure.persistence.sqlalchemy.models.composite import ( # noqa: F401 + FactorCompositeModel, +) +from app.infrastructure.persistence.sqlalchemy.models.condition_field import ( # noqa: F401 + ConditionFieldModel, +) from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: F401 FactorDefinitionModel, ) diff --git a/backend/app/infrastructure/persistence/sqlalchemy/models/condition_field.py b/backend/app/infrastructure/persistence/sqlalchemy/models/condition_field.py new file mode 100644 index 0000000..2292124 --- /dev/null +++ b/backend/app/infrastructure/persistence/sqlalchemy/models/condition_field.py @@ -0,0 +1,31 @@ +"""字段库表(2026-10)。 + +condition_field:过滤条件字段的目录契约源(name 主键幂等)。 +内置字段由 quant/condition_fields.py 的注册表在 API 首次读取时 seed(只补不删), +自定义字段与用户改过的文案都落在这张表里。 +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.infrastructure.persistence.sqlalchemy.base import Base + + +class ConditionFieldModel(Base): + __tablename__ = "condition_field" + + name: Mapped[str] = mapped_column(String(64), primary_key=True) + label: Mapped[str] = mapped_column(String(64), default="") + description: Mapped[str] = mapped_column(String(500), default="") + kind: Mapped[str] = mapped_column(String(8), default="num") + group_name: Mapped[str] = mapped_column(String(32), default="行情") + unit: Mapped[str] = mapped_column(String(16), default="") + source: Mapped[str] = mapped_column(String(8), default="builtin") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + sort_order: Mapped[int] = mapped_column(Integer, default=100) + created_at: Mapped[datetime] = mapped_column(DateTime) + updated_at: Mapped[datetime] = mapped_column(DateTime) \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/sqlalchemy/models/factor.py b/backend/app/infrastructure/persistence/sqlalchemy/models/factor.py index 792f4e1..e7dcf6b 100644 --- a/backend/app/infrastructure/persistence/sqlalchemy/models/factor.py +++ b/backend/app/infrastructure/persistence/sqlalchemy/models/factor.py @@ -1,13 +1,16 @@ -"""因子目录表(M7.1)。 +"""因子目录表(M7.1;2026-10 支持参数化实例)。 -factor_definition:因子元数据契约源(name 主键幂等);requires 以 JSON 存。 +factor_definition:因子名(主键,参数化实例的参数就写在名字里)→ 元数据;requires 以 JSON 存。 +`enabled` 是唯一由人配置的字段(是否出现在因子下拉里);内置实例的开关注由代码注册表 +收敛(见 application/services/factor_catalog.py),停用只影响「能不能被选中」, +不影响已引用它的策略/归档解析 —— 历史不能被开关改义。 """ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy import Boolean, DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.infrastructure.persistence.sqlalchemy.base import Base @@ -16,7 +19,8 @@ from app.infrastructure.persistence.sqlalchemy.base import Base class FactorDefinitionModel(Base): __tablename__ = "factor_definition" - name: Mapped[str] = mapped_column(String(64), primary_key=True) + # 128:参数化实例的名字把参数写全(如 momentum(window=90,direction=lower_is_better)) + name: Mapped[str] = mapped_column(String(128), primary_key=True) description: Mapped[str] = mapped_column(String(500), default="") formula: Mapped[str] = mapped_column(String(500), default="") brief: Mapped[str] = mapped_column(String(500), default="") @@ -25,4 +29,5 @@ class FactorDefinitionModel(Base): direction: Mapped[str] = mapped_column(String(32), default="higher_is_better") requires_json: Mapped[str] = mapped_column(Text, default="[]") version: Mapped[str] = mapped_column(String(16), default="1") + enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1") created_at: Mapped[datetime] = mapped_column(DateTime) diff --git a/backend/app/infrastructure/persistence/sqlalchemy/models/strategy.py b/backend/app/infrastructure/persistence/sqlalchemy/models/strategy.py index 80e23e1..2fe16aa 100644 --- a/backend/app/infrastructure/persistence/sqlalchemy/models/strategy.py +++ b/backend/app/infrastructure/persistence/sqlalchemy/models/strategy.py @@ -16,7 +16,8 @@ class StrategyModel(Base): id: Mapped[str] = mapped_column(String(32), primary_key=True) name: Mapped[str] = mapped_column(String(64), unique=True) description: Mapped[str] = mapped_column(String(300), default="") - spec_type: Mapped[str] = mapped_column(String(16), default="backtest") + # 2026-09 重构后策略库只存选股策略,新行一律 selection(历史行的 backtest 由数据迁移收敛) + spec_type: Mapped[str] = mapped_column(String(16), default="selection") config_json: Mapped[str] = mapped_column(Text) version: Mapped[str] = mapped_column(String(16), default="1") created_at: Mapped[datetime] = mapped_column(DateTime) diff --git a/backend/app/infrastructure/persistence/sqlalchemy/repositories/condition_field_impl.py b/backend/app/infrastructure/persistence/sqlalchemy/repositories/condition_field_impl.py new file mode 100644 index 0000000..ee32770 --- /dev/null +++ b/backend/app/infrastructure/persistence/sqlalchemy/repositories/condition_field_impl.py @@ -0,0 +1,102 @@ +"""字段库 Repository 的 SQLAlchemy 实现(2026-10)。""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.domain.entities.condition_field import ConditionField +from app.infrastructure.persistence.sqlalchemy.models.condition_field import ConditionFieldModel + + +def _to_entity(row: ConditionFieldModel) -> ConditionField: + return ConditionField( + name=row.name, + label=row.label, + description=row.description, + kind=row.kind, + group_name=row.group_name, + unit=row.unit, + source=row.source, + enabled=row.enabled, + sort_order=row.sort_order, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +class SqlAlchemyConditionFieldRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def list(self) -> list[ConditionField]: + rows = self._session.scalars( + select(ConditionFieldModel).order_by( + ConditionFieldModel.sort_order, ConditionFieldModel.name + ) + ).all() + return [_to_entity(r) for r in rows] + + def get(self, name: str) -> ConditionField | None: + row = self._session.get(ConditionFieldModel, name) + return _to_entity(row) if row else None + + def insert_missing(self, items: list[ConditionField]) -> int: + if not items: + return 0 + existing = set( + self._session.scalars( + select(ConditionFieldModel.name).where( + ConditionFieldModel.name.in_([i.name for i in items]) + ) + ) + ) + now = datetime.now() + added = 0 + for item in items: + if item.name in existing: + continue # 只补不删、不覆盖用户改过的文案 + self._session.add( + ConditionFieldModel( + name=item.name, + label=item.label, + description=item.description, + kind=item.kind, + group_name=item.group_name, + unit=item.unit, + source=item.source, + enabled=item.enabled, + sort_order=item.sort_order, + created_at=now, + updated_at=now, + ) + ) + added += 1 + return added + + def save(self, item: ConditionField) -> ConditionField: + now = datetime.now() + row = self._session.get(ConditionFieldModel, item.name) + if row is None: + row = ConditionFieldModel(name=item.name, created_at=now, updated_at=now) + self._session.add(row) + row.label = item.label + row.description = item.description + row.kind = item.kind + row.group_name = item.group_name + row.unit = item.unit + row.source = item.source + row.enabled = item.enabled + row.sort_order = item.sort_order + row.updated_at = now + self._session.flush() + return _to_entity(row) + + def delete(self, name: str) -> bool: + row = self._session.get(ConditionFieldModel, name) + if row is None: + return False + self._session.delete(row) + return True \ No newline at end of file diff --git a/backend/app/infrastructure/persistence/sqlalchemy/repositories/factor_impl.py b/backend/app/infrastructure/persistence/sqlalchemy/repositories/factor_impl.py index 49afc6b..53be691 100644 --- a/backend/app/infrastructure/persistence/sqlalchemy/repositories/factor_impl.py +++ b/backend/app/infrastructure/persistence/sqlalchemy/repositories/factor_impl.py @@ -1,4 +1,8 @@ -"""因子目录 Repository 的 SQLAlchemy 实现(M7.1)。""" +"""因子目录 Repository 的 SQLAlchemy 实现(M7.1;2026-10 加 enabled)。 + +只写**真列**:实体上还有 template/params/label/param_specs 等由名字解析出来的投影字段, +它们不是列 —— 若照 model_dump 全量 setattr,会出现「看着写进去了、其实没落库」的假象。 +""" from __future__ import annotations @@ -9,7 +13,23 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.domain.entities.factor import FactorDefinition -from app.infrastructure.persistence.sqlalchemy.models.factor import FactorDefinitionModel +from app.infrastructure.persistence.sqlalchemy.models.factor import ( + FactorDefinitionModel, +) + +# 真正落库的列(其余实体字段是解析投影) +_STORED_COLUMNS = ( + "name", + "description", + "formula", + "brief", + "frequency", + "lookback", + "direction", + "requires", + "version", + "enabled", +) def _to_entity(row: FactorDefinitionModel) -> FactorDefinition: @@ -23,6 +43,7 @@ def _to_entity(row: FactorDefinitionModel) -> FactorDefinition: direction=row.direction, requires=json.loads(row.requires_json or "[]"), version=row.version, + enabled=bool(row.enabled), created_at=row.created_at, ) @@ -57,11 +78,13 @@ class SqlAlchemyFactorRepository: direction=d.direction, requires_json=json.dumps(d.requires), version=d.version, + enabled=d.enabled, created_at=now, ) ) else: - for k, v in d.model_dump(exclude={"created_at"}).items(): + for k in _STORED_COLUMNS: + v = getattr(d, k) if k == "requires": v = json.dumps(v) setattr(row, k, v) diff --git a/backend/app/quant/condition_fields.py b/backend/app/quant/condition_fields.py new file mode 100644 index 0000000..67b7c38 --- /dev/null +++ b/backend/app/quant/condition_fields.py @@ -0,0 +1,375 @@ +"""条件字段注册表 —— 「字段库」的唯一事实来源(2026-10)。 + +要解决的问题 +------------ +策略库的「过滤条件」此前是**手填字段名**的输入框:用户必须知道 dv_ratio / +static.industry / fundamental.roe 这类内部标识,既看不到含义,写错了也不报错 —— +引擎对未知字段求值一律返回 None,条件**永远不通过**,策略会安静地选出 0 只股票。 +这正是 AGENT.md 禁止的「静默失败 / 假装支持」。 + +本模块的职责 +------------ +把**引擎真正支持的字段域**集中声明一次(中文名 + 含义 + 单位 + 分组 + 类型 + 排序), +供三方共用: + +1. ``/api/condition-fields`` 据此 seed 目录、据此校验用户新增的自定义字段; +2. 前端据此渲染分组下拉、含义提示、并按类型收窄可选比较符; +3. :func:`is_supported_field` 直接查 ``Stock`` / ``FinancialIndicator`` 的字段定义与 + 因子注册表(``quant.factors``),**不另写一套近似规则** —— 避免注册表与引擎漂移。 + +诚实性约束(AGENT.md §24) +-------------------------- +只登记真能算的字段。日期字段(如 ``static.list_date``)无法比较大小,:func:`reason_unsupported` +会明确拒绝并说明理由,而不是放行让用户建出一条「永远选不出股票」的条件。 +单位一律照抄数据源落库口径(见 ``data_sources/tushare.py`` 的换算注释),不凭印象写。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.domain.entities.condition_field import OPS_BY_KIND +from app.domain.entities.market import ( + DAILY_BAR_NUMERIC_FIELDS, + DAILY_BASIC_NUMERIC_FIELDS, + FinancialIndicator, + Stock, +) +from app.quant.factors import FactorError, get_factor, list_factors, resolve_factor + +# ---------- 分组(下拉的 optgroup 顺序即此顺序) ---------- + +GROUP_STOCK = "股票基础" +GROUP_QUOTE = "行情" +GROUP_TECH = "技术指标" +GROUP_DAILY = "每日指标" +GROUP_FUNDAMENTAL = "财务指标" +GROUP_FACTOR = "因子" + +GROUP_ORDER: tuple[str, ...] = ( + GROUP_STOCK, + GROUP_QUOTE, + GROUP_TECH, + GROUP_DAILY, + GROUP_FUNDAMENTAL, + GROUP_FACTOR, +) + +# 比较符规则(哪种类型能比大小)定义在领域实体 domain/entities/condition_field.py, +# 此处转发 —— 保证「字段库 API 返回的 ops」与「注册表里 FieldDef.ops」出自同一处。 + +# ---------- 单位阶梯(2026-10) ---------- +# +# 单位分两层,避免「改个显示单位把历史策略的数值偷偷换义」: +# · **基准单位**(FieldDef.unit):引擎存储与比较用的单位,写死在数据源落库口径里, +# 不可改。归档里的 ConditionSpec 存的永远是基准单位值 —— 复现不受界面设置影响。 +# · **界面单位**(本阶梯里的备选项):只在「输入/显示」这一层做换算,factor 表示 +# 「该单位 → 基准单位的系数」(即提交前 ×factor,回显时 ÷factor)。 +# 所以用户在字段库把总市值选成「亿元」,输入 5 会存成 50000(万元)——引擎比较的仍是 +# 基准单位,而界面上看到的始终是 5 亿元。改单位不会让任何历史策略变义。 +# +# 只登记换算无歧义、且实际会用到的单位:金额(元/万元/亿元)、股数(股/手/万手)、 +# 股本(万股/亿股)。百分数(%)与倍数(倍)不提供备选 —— 换成小数只会制造误读。 + +U_MONEY_YUAN: tuple[tuple[str, float], ...] = (("元", 1.0), ("万元", 1e4), ("亿元", 1e8)) +U_MONEY_WAN: tuple[tuple[str, float], ...] = (("万元", 1.0), ("亿元", 1e4)) +U_SHARE_GU: tuple[tuple[str, float], ...] = (("股", 1.0), ("手", 100.0), ("万手", 1e6)) +U_SHARE_WAN: tuple[tuple[str, float], ...] = (("万股", 1.0), ("亿股", 1e4)) + +# 哪些字段提供备选界面单位(键 = 引擎字段名;不在表里的字段只能用基准单位) +_UNIT_LADDERS: dict[str, tuple[tuple[str, float], ...]] = { + # 行情原列:volume 入库为股(源为手 ×100),amount 入库为元(源为千元 ×1000) + "volume": U_SHARE_GU, + "amount": U_MONEY_YUAN, + # 每日指标:市值为万元,股本为万股 + "total_mv": U_MONEY_WAN, + "circ_mv": U_MONEY_WAN, + "total_share": U_SHARE_WAN, + "float_share": U_SHARE_WAN, + "free_share": U_SHARE_WAN, + # 财务指标:金额入库为元 + "fundamental.net_profit": U_MONEY_YUAN, + "fundamental.total_revenue": U_MONEY_YUAN, +} + + +@dataclass(frozen=True) +class FieldDef: + """一个条件字段的登记项(引擎真能算的字段)。""" + + name: str # 引擎字段名(写进 condition.field) + label: str # 中文名(下拉里给人看的) + description: str # 含义 / 口径(含单位),必须可核对 + kind: str # num | str + group_name: str + unit: str = "" # **基准单位**(引擎存储/比较用),不可由界面更改 + curated: bool = True # True=默认进字段库;False=仅登记为「可新增」(少用字段) + units: tuple[tuple[str, float], ...] = () # 可选界面单位;(单位, →基准单位系数),首项须是基准单位 + + @property + def ops(self) -> tuple[str, ...]: + return OPS_BY_KIND.get(self.kind, ()) + + @property + def unit_options(self) -> tuple[tuple[str, float], ...]: + """可选界面单位;未登记阶梯的字段只有基准单位一项(界面不给选择)。""" + return self.units or ((self.unit, 1.0),) + + +# ---------- 股票基础(static.*):来自 Stock 实体,只有字符串字段可比较 ---------- + +# (attr, 中文名, 含义, curated) +_STATIC_FIELDS: tuple[tuple[str, str, str, bool], ...] = ( + ("industry", "所属行业", "股票基础信息里的行业名称(字符串),如「银行」「白酒」。等值用「=」,多值用「属于」。", True), + ("market", "上市板块", "主板 / 创业板 / 科创板 / 北交所(字符串)。", True), + ("area", "注册地域", "公司注册地省份或地区(字符串),如「广东」「北京」。", True), + ("exchange", "交易所", "SH 上交所 / SZ 深交所 / BJ 北交所(字符串)。", False), + ("status", "上市状态", "L 上市 / D 退市 / P 暂停上市(字符串)。研究池已默认剔除退市股。", False), + ("name", "股票名称", "证券简称(字符串)。一般用于核对,不建议拿来做条件。", False), + ("symbol", "股票代码", "Tushare 风格代码,如 600519.SH(字符串)。多值用「属于」。", False), +) + +# ---------- 行情原列(open/high/low/close/volume/amount) ---------- +# 换算口径见 data_sources/tushare.py: volume=vol(手)*100 → 股;amount=amount(千元)*1000 → 元。 + +_BAR_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = ( + ("close", "收盘价", "当日收盘价(元)。复权口径由公共配置的 price_adjustment 决定(默认 hfq)。", "元", True), + ("open", "开盘价", "当日开盘价(元),复权口径同上。", "元", True), + ("high", "最高价", "当日最高价(元),复权口径同上。", "元", True), + ("low", "最低价", "当日最低价(元),复权口径同上。", "元", True), + ("volume", "成交量", "当日成交股数(股)。数据源原始单位为「手」,入库时已 ×100 换算。", "股", True), + ("amount", "成交额", "当日成交金额(元)。数据源原始单位为「千元」,入库时已 ×1000 换算。", "元", True), +) + +# ---------- 技术派生(滚动窗口计算,非行情原列) ---------- + +_TECH_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = ( + ("ma20", "20 日均线", "收盘价的 20 个交易日简单移动平均(元),在选股日当日取值。", "元", True), + ("ma60", "60 日均线", "收盘价的 60 个交易日简单移动平均(元),在选股日当日取值。", "元", True), +) + +# ---------- 每日指标(daily_basic) ---------- +# 单位照抄 data_sources/tushare.py: 百分数/倍数为原样,股本为万股,市值为万元。 + +_DAILY_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = ( + ("dv_ratio", "股息率", "近 12 个月现金分红 / 总市值 × 100(%),逐日时点值 —— 即因子 dividend_yield 的口径。", "%", True), + ("dv_ttm", "股息率 TTM", "近 12 个月滚动现金分红 / 总市值 × 100(%),即因子 dividend_yield_ttm 的口径。", "%", True), + ("pe", "市盈率 PE", "总市值 / 最新年报净利润(倍,静态口径)。", "倍", True), + ("pe_ttm", "市盈率 PE(TTM)", "总市值 / 最近 12 个月净利润(倍)。", "倍", True), + ("pb", "市净率 PB", "总市值 / 最新报告期净资产(倍)。", "倍", True), + ("turnover_rate", "换手率", "当日成交股数 / 流通股本 × 100(%)。", "%", True), + ("volume_ratio", "量比", "当日成交量 / 过去 5 日平均成交量(倍)。", "倍", True), + ("total_mv", "总市值", "总股本 × 当日收盘价(万元)。", "万元", True), + ("circ_mv", "流通市值", "流通股本 × 当日收盘价(万元)。", "万元", True), + ("ps", "市销率 PS", "总市值 / 最新年报营业收入(倍)。", "倍", False), + ("ps_ttm", "市销率 PS(TTM)", "总市值 / 最近 12 个月营业收入(倍)。", "倍", False), + ("total_share", "总股本", "总股本(万股)。", "万股", False), + ("float_share", "流通股本", "流通股本(万股)。", "万股", False), + ("free_share", "自由流通股本", "自由流通股本(万股)。", "万股", False), +) + +# ---------- 财务指标(fundamental.*) ---------- +# 可见性口径:只取 announce_date <= 选股日的最新已公告值(防未来函数,见 selection.run_condition_selection)。 + +_FUNDAMENTAL_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = ( + ("roe", "净资产收益率 ROE", "最新已公告报告期的净资产收益率(%)。", "%", True), + ("eps", "每股收益 EPS", "最新已公告报告期的每股收益(元)。", "元", True), + ("gross_margin", "毛利率", "最新已公告报告期的毛利率(%)。", "%", True), + ("net_profit", "归母净利润", "最新已公告报告期的归母净利润(元)。", "元", False), + ("total_revenue", "营业总收入", "最新已公告报告期的营业总收入(元)。注意:目前只有新浪兜底源提供该字段,多数行可能为空 —— 缺失时条件视为不通过。", "元", False), +) + +# static.* 里明确不支持的字段(存在但不可比较) +_STATIC_UNSUPPORTED: dict[str, str] = { + "list_date": "上市日期是日期,不是可比较的数值/字符串;请改用股票池的「上市天数」设置", + "delist_date": "退市日期是日期,不是可比较的数值/字符串", +} + + +def _defs() -> list[FieldDef]: + """构造全部内置字段定义(每次调用重新构造,保证与代码注册表实时一致)。""" + out: list[FieldDef] = [] + for attr, label, desc, curated in _STATIC_FIELDS: + out.append( + FieldDef( + name=f"static.{attr}", + label=label, + description=desc, + kind="str", + group_name=GROUP_STOCK, + curated=curated, + ) + ) + for name, label, desc, unit, curated in _BAR_FIELDS: + out.append( + FieldDef( + name, label, desc, "num", GROUP_QUOTE, unit, curated, + _UNIT_LADDERS.get(name, ()), + ) + ) + for name, label, desc, unit, curated in _TECH_FIELDS: + out.append( + FieldDef(name, label, desc, "num", GROUP_TECH, unit, curated, _UNIT_LADDERS.get(name, ())) + ) + for name, label, desc, unit, curated in _DAILY_FIELDS: + out.append( + FieldDef(name, label, desc, "num", GROUP_DAILY, unit, curated, _UNIT_LADDERS.get(name, ())) + ) + for name, label, desc, unit, curated in _FUNDAMENTAL_FIELDS: + full = f"fundamental.{name}" + out.append( + FieldDef(full, label, desc, "num", GROUP_FUNDAMENTAL, unit, curated, _UNIT_LADDERS.get(full, ())) + ) + for d in list_factors(): + # 因子既能当「打分因子」也能当「过滤条件」:这里复用因子注册表的元数据, + # 不另写描述,避免两处文案漂移。因子的 description 里已写明公式与口径; + # 标签用中文名(含参数),如「动量(窗口 60,越高越好)」—— + # 参数化实例不在这里(它们在 /factors 目录里,条件下拉按名并入)。 + out.append( + FieldDef( + name=d.name, + label=f"{d.display}(因子)", + description=d.description + (f" 用法:{d.brief}" if d.brief else ""), + kind="num", + group_name=GROUP_FACTOR, + curated=True, + ) + ) + return out + + +def builtin_fields() -> list[FieldDef]: + """全部引擎支持的字段(含 curated=False 的「可新增但不默认展示」项)。 + + 名字唯一性由因子名与各分组前缀保证;一旦重复说明注册表写错,直接抛错而非静默覆盖。 + 单位阶梯同样自检:首项必须是基准单位且系数为 1.0,系数必须为正 —— 写错了会让 + 「界面显示 5 亿元、引擎按 5 万元比」这种错静默溜进生产。 + """ + defs = _defs() + names = [d.name for d in defs] + dup = {n for n in names if names.count(n) > 1} + if dup: + raise ValueError(f"条件字段注册表存在重名:{sorted(dup)}") + for d in defs: + if not d.units: + continue + base, factor = d.units[0] + if base != d.unit or factor != 1.0: + raise ValueError( + f"字段 {d.name} 的单位阶梯首项必须是基准单位 {d.unit!r}(系数 1.0),实际 {d.units[0]!r}" + ) + if any(f <= 0 for _, f in d.units): + raise ValueError(f"字段 {d.name} 的单位换算系数必须为正:{d.units}") + if len({u for u, _ in d.units}) != len(d.units): + raise ValueError(f"字段 {d.name} 的单位阶梯有重复单位:{d.units}") + return defs + + +def curated_fields() -> list[FieldDef]: + """默认进「字段库」的字段(下拉里开箱可见的那批)。""" + return [d for d in builtin_fields() if d.curated] + + +def get_field(name: str) -> FieldDef | None: + """按字段名取定义:注册表字段,或**参数化因子键**(如 momentum(window=90,direction=…))。 + + 为什么参数化因子也要能取到:它是引擎真认的条件字段(`momentum_60 > 0` 一直合法), + 而字段库/说明书的单位后缀、类型判断都走这里。取不到会让人误以为「引擎不支持」, + 甚至让「把参数化因子加进字段库」这一步半路 assert 崩掉(500 而不是 422)。 + """ + for d in builtin_fields(): + if d.name == name: + return d + return _factor_field(name) + + +def _factor_field(name: str) -> FieldDef | None: + """参数化因子键 → 字段定义(因子是无量纲量,不带单位)。""" + try: + defn, _fn = resolve_factor(name) + except FactorError: + return None + if defn.name in {d.name for d in builtin_fields()}: + return None # 注册表字段已在上一步返回;这里只处理新增的参数化实例 + return FieldDef( + name=defn.name, + label=f"{defn.display}(因子)", + description=defn.description + (f" 用法:{defn.brief}" if defn.brief else ""), + kind="num", + group_name=GROUP_FACTOR, + curated=False, # 不自动进字段库:目录在 /factors 管,条件里按名并进来 + ) + + +def _stock_attrs() -> set[str]: + return set(Stock.model_fields) + + +def _fundamental_attrs() -> set[str]: + """FinancialIndicator 里可比较的数值字段(排除 symbol/报告期/来源等元数据)。""" + skip = {"symbol", "report_date", "announce_date", "source"} + return {n for n in FinancialIndicator.model_fields if n not in skip} + + +def reason_unsupported(name: str) -> str: + """字段不可用的理由(用于 422 文案;可用字段返回空串)。""" + if not name or not name.strip(): + return "字段名为空" + if name in DAILY_BAR_NUMERIC_FIELDS: + return "" + if name in ("ma20", "ma60"): + return "" + if name in DAILY_BASIC_NUMERIC_FIELDS: + return "" + if name.startswith("static."): + attr = name[len("static.") :] + if attr in _STATIC_UNSUPPORTED: + return f"{name} 不可用作条件:{_STATIC_UNSUPPORTED[attr]}" + if attr in _stock_attrs(): + return "" + return f"{name} 不存在:股票基础信息里没有 {attr} 字段" + if name.startswith("fundamental."): + attr = name[len("fundamental.") :] + if attr in _fundamental_attrs(): + return "" + return f"{name} 不存在:财务指标里没有 {attr} 字段" + try: + get_factor(name) + except FactorError: + return ( + f"{name} 不是引擎支持的字段。可用:行情列({', '.join(DAILY_BAR_NUMERIC_FIELDS)})、" + "ma20/ma60、每日指标列、static.<股票基础字段>、fundamental.<财务字段>、" + "已注册因子名,或参数化因子键(如 momentum(window=90,direction=higher_is_better))" + ) + return "" + + +def is_supported_field(name: str) -> bool: + """引擎是否真能算这个字段(False = 条件永远不通过,必须拒绝)。""" + return reason_unsupported(name) == "" + + +def available_fields(existing: set[str]) -> list[FieldDef]: + """引擎支持但**尚未进目录**的字段(用户「新增字段」时可选项)。 + + 只从注册表里挑,用户因此不可能加进一个引擎算不出来的字段(§24 不假装支持)。 + """ + return [d for d in builtin_fields() if not d.curated and d.name not in existing] + + +# ---------- 单位换算(界面单位 ⇄ 基准单位) ---------- + + +def unit_options(name: str) -> list[tuple[str, float]]: + """该字段可选的界面单位(首项为基准单位,系数 = 该单位 → 基准单位)。字段不存在 → 空列表。 + + **换算在界面层做**(前端按系数换算输入/回显),存储与引擎一律用基准单位: + 这样归档里的 ConditionSpec 永远不随界面设置改变含义。 + """ + d = get_field(name) + return list(d.unit_options) if d else [] + + +def unit_allowed(name: str, unit: str) -> bool: + """该单位是否在字段允许的阶梯里(API 据此 422 拒绝自由文本单位)。""" + return unit in {u for u, _ in unit_options(name)} \ No newline at end of file diff --git a/backend/app/quant/factors.py b/backend/app/quant/factors.py index 0c6c502..0543d68 100644 --- a/backend/app/quant/factors.py +++ b/backend/app/quant/factors.py @@ -1,4 +1,34 @@ -"""因子引擎:因子注册表、元数据与计算(Phase 2,低频选股因子)。 +"""因子引擎:因子**模板**(含可编辑参数)、实例注册表与计算(Phase 2 起,2026-10 参数化)。 + +## 三层概念(这是本模块的核心约定) + +1. **模板(FactorTemplate)**:算法的家族,如 `momentum`(动量)、`volatility`(波动率)。 + 模板声明「哪些参数可编辑、允许范围、默认值」以及计算函数 `fn(fields, params)`。 +2. **参数(params)**:模板的可编辑取值,如 `window=90`、`direction=lower_is_better`。 + 参数约束是**受控范围**(整数区间 / 枚举),不允许自由值 —— 见 AGENT.md §24: + 写不进去就报错,绝不静默接受一个引擎其实不支持的设置。 +3. **因子实例(FactorDef)**:`(模板, 参数)` 的具体因子,**名字里带着全部参数**: + + momentum_60 ← 内置实例(代码里登记的历史名) + momentum(window=90,direction=higher_is_better) ← 参数化实例(目录里创建) + + 实例名即身份:参数写进名字,任何地方(策略 JSON、归档 spec、组合组件、条件字段) + 存下这个名字,就同时冻结了「用哪个模板 + 哪些参数」——**历史归档不会因为之后 + 改了什么参数而改变含义**。这也是为什么不把参数放在另一个字段里:那需要改动 + 所有已经存了因子名的地方(策略/归档/回放/信号/Agent 工具),而且容易漏。 + +## 参数与默认值:为什么键里总是写全 direction + +键里**不省略任何可编辑参数**(哪怕等于模板默认值)。若省略,`momentum(window=90)` +的含义就取决于「模板默认方向」这一代码事实:将来代码把默认方向一改,用户已经存下的 +策略/归档会跟着变义 —— 与「单位」那次拒绝的做法同理。写全参数后,键自解释、 +不依赖任何默认值,改默认值只影响新建实例。 + +## 兼容性 + +`get_factor()` 现在能吃两种名字:注册表里的历史名(内置实例)与参数化键; +`compute_factor()`、`list_factors()` 的行为保持不变,因此下游(选股/回测/组合/ +说明书/条件字段/Agent 工具)无需感知参数化的存在,也**不会绕过参数校验**。 数据形态:行情长表 DataFrame(列 symbol/trade_date/close/high/low/volume/amount, 以及经 ResearchService 并入的每日指标列如 dv_ratio/dv_ttm), @@ -10,15 +40,68 @@ from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass +import inspect +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any import pandas as pd +DIRECTION_HIGHER = "higher_is_better" +DIRECTION_LOWER = "lower_is_better" +DIRECTIONS = (DIRECTION_HIGHER, DIRECTION_LOWER) + +# 参数名常量(键里的字面量,改它等于改所有已存键的含义,别改) +P_WINDOW = "window" +P_FAST = "fast" +P_SLOW = "slow" +P_DIRECTION = "direction" + +# 窗口参数的允许范围:受控区间而非固定档(任意整数都能算,但要有边界)。 +# 上限 500 个交易日 ≈ 两年,够长;下限 2 是因为 shift(1)/rolling(1) 的波动率无意义。 +WINDOW_MIN = 2 +WINDOW_MAX = 500 + + +@dataclass(frozen=True) +class ParamSpec: + """一个可编辑参数的约束(受控范围,越界一律报错而不是截断/静默忽略)。""" + + name: str + label: str + kind: str # "int" | "enum" + default: Any + minimum: int | None = None + maximum: int | None = None + choices: tuple[str, ...] = () + note: str = "" + + def describe(self) -> str: + """人类可读的约束说明(用于错误文案与目录展示)。""" + if self.kind == "enum": + return "、".join(self.choices) + if self.minimum is not None and self.maximum is not None: + return f"{self.minimum} ~ {self.maximum} 的整数" + return "整数" + + +DIRECTION_SPEC = ParamSpec( + name=P_DIRECTION, + label="方向", + kind="enum", + default=DIRECTION_HIGHER, + choices=DIRECTIONS, + note="越高越好 / 越低越好:决定复合分里的排序方向(低为好自动取负)。", +) + @dataclass(frozen=True) class FactorDef: - """因子元数据(AGENT.md §22 要求逐项明确)。""" + """因子元数据(AGENT.md §22 要求逐项明确)。 + + 新增字段都带默认值:历史代码用位置参数构造 FactorDef 的地方不受影响。 + """ name: str description: str @@ -28,9 +111,55 @@ class FactorDef: lookback: int = 20 direction: str = "higher_is_better" # | lower_is_better requires: tuple[str, ...] = ("close",) + # ---- 参数化(2026-10)---- + template: str = "" # 模板名,如 "momentum";空串 = 手工登记的老式因子 + params: Mapping[str, Any] = field(default_factory=dict) # 冻结的参数取值 + param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面) + source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例) + label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」 + + @property + def display(self) -> str: + """界面用显示名:没有 label 时退回 name(老因子/自定义登记行)。""" + return self.label or self.name -FactorFn = Callable[[dict[str, pd.DataFrame]], pd.DataFrame] +FactorFn = Callable[[dict[str, pd.DataFrame], Mapping[str, Any]], pd.DataFrame] + + +@dataclass(frozen=True) +class FactorTemplate: + """算法家族 + 可编辑参数声明 + 内置实例(历史名)。""" + + name: str + label: str # 中文家族名,如「动量」 + description: str # 可含 {window} / {fast} / {slow} 占位 + formula: str + brief: str + fn: FactorFn + param_specs: tuple[ParamSpec, ...] = () + requires: tuple[str, ...] = ("close",) + frequency: str = "daily" + direction_default: str = DIRECTION_HIGHER + lookback_of: Callable[[Mapping[str, Any]], int] | None = None + check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束 + instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...) + label_of: Callable[[Mapping[str, Any]], str] | None = None + + def specs(self) -> tuple[ParamSpec, ...]: + """全部可编辑参数(模板自己的参数 + 方向,方向恒在最后)。""" + direction = ParamSpec( + name=DIRECTION_SPEC.name, + label=DIRECTION_SPEC.label, + kind=DIRECTION_SPEC.kind, + default=self.direction_default, + choices=DIRECTION_SPEC.choices, + note=DIRECTION_SPEC.note, + ) + return (*self.param_specs, direction) + + def defaults(self) -> dict[str, Any]: + return {s.name: s.default for s in self.specs()} class FactorError(ValueError): @@ -38,42 +167,318 @@ class FactorError(ValueError): _REGISTRY: dict[str, tuple[FactorDef, FactorFn]] = {} +_TEMPLATES: dict[str, FactorTemplate] = {} + +# 参数化键:template(k=v,k=v)。模板名与参数名限定为标识符,值限定为标识符/数字, +# 避免出现靠运气才能解析的名字(宁可在创建时就被拒)。 +_KEY_RE = re.compile(r"^(?P