Files
qlib/backend/tests/test_agent_v3_tools.py
Simon 2e90f3eeac 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。
2026-10-01 16:33:32 +08:00

154 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""D4 Agent 补齐至 v3 §25(14 工具)测试:inspect_factor / create_composite_factor /
get_backtest_result / create_experiment。tmp SQLite + 真实 repo factories。"""
from __future__ import annotations
from datetime import date, datetime
import pytest
from app.agent.tools_impl import build_tools
from app.domain.entities.factor import FactorDefinition
from app.domain.entities.market import Stock
from app.domain.entities.research import (
ExperimentRecord,
JobRecord,
JobStatus,
ResearchSpec,
)
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import (
SqlAlchemyFactorRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
SqlAlchemyJobRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
SqlAlchemyStockRepository,
)
from app.quant.factors import list_factors
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
_SYMS = ["600000.SH", "600001.SH", "600002.SH"]
@pytest.fixture()
def tools(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'agent.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
df = synthetic_daily({s: 0.006 - 0.0015 * i for i, s in enumerate(_SYMS)}, n=320)
with Session() as session:
SqlAlchemyStockRepository(session).upsert_many(
[Stock(symbol=s, name=f"测试{i}", list_date=date(1999, 1, 1)) for i, s in enumerate(_SYMS)]
)
SqlAlchemyDailyBarRepository(session).upsert_many(bars_dataframe_to_daily_bars(df))
SqlAlchemyFactorRepository(session).upsert_many(
[FactorDefinition.from_registry_def(d) for d in list_factors()]
)
# 造一条成功 Job(无 experiment 关联,供 create_experiment 补档)
spec = ResearchSpec(
type="backtest",
universe={"exclude_st": False, "min_listing_days": 0, "symbols": _SYMS},
factors=[{"name": "momentum_60", "weight": 1.0}],
selection={"top_n": 1},
rebalance="monthly",
period=(date(2024, 5, 1), date(2024, 8, 31)),
)
from app.quant.engine import LocalEngine
result = LocalEngine().run_backtest(df, spec)
SqlAlchemyJobRepository(session).create(
JobRecord(id="JOB-D4", kind="backtest",
spec_json=spec.model_dump_json(), status=JobStatus.SUCCESS,
result_json=result.model_dump_json(),
experiment_id=None,
created_at=datetime.now(), finished_at=datetime.now())
)
SqlAlchemyExperimentRepository(session).save(
ExperimentRecord(id="EXP-D4", kind="backtest", spec_json=spec.model_dump_json(),
result_json=result.model_dump_json(),
summary_text="d4", job_id="JOB-D4",
created_at=datetime.now())
)
session.commit()
factories = {
"session_factory": lambda: Session(),
"stock_repo_factory": lambda s: SqlAlchemyStockRepository(s),
"daily_repo_factory": lambda s: SqlAlchemyDailyBarRepository(s),
"experiment_repo_factory": lambda s: SqlAlchemyExperimentRepository(s),
"engine": None,
}
return build_tools(factories=factories)
def _invoke(tools, name: str, args: dict) -> str:
tool = next(t for t in tools if t.name == name)
return tool.invoke(args)
class TestV3Tools:
def test_tool_count_14(self, tools) -> None:
names = {t.name for t in tools}
assert len(names) == 14
assert {
"inspect_factor", "create_composite_factor", "get_backtest_result", "create_experiment"
} <= names
def test_inspect_factor(self, tools) -> None:
out = _invoke(tools, "inspect_factor", {"name": "momentum_60"})
assert "momentum_60" in out and "公式" in out and "lookback" in out
miss = _invoke(tools, "inspect_factor", {"name": "no_such"})
# 走引擎解析:报「不可用 + 可用列表」比含糊的「不在目录」有用
# (参数化因子常常还没进目录就能算,所以目录不是准入门槛)
assert "不可用" in miss
def test_inspect_parameterized_factor(self, tools) -> None:
"""参数化键(还没进目录)也要能问出参数:这是「暴露真实筛选参数」的一环。"""
key = "momentum(window=90,direction=lower_is_better)"
out = _invoke(tools, "inspect_factor", {"name": key})
assert key in out
assert "窗口" in out or "window=90" in out
assert "越低越好" in out and "参数:window=90" in out
assert "90" in out
def test_create_composite_factor(self, tools) -> None:
out = _invoke(
tools, "create_composite_factor",
{"name": "动量低波", "factors": "momentum_60:0.7,volatility_60:0.3"},
)
assert "组合已保存" in out and "CF-" in out
bad = _invoke(tools, "create_composite_factor",
{"name": "x", "factors": "no_such:1"})
assert "无法创建" in bad
def test_composite_accepts_parameterized_factor(self, tools) -> None:
"""参数化键里有逗号:逗号切分必须括号感知,否则会被劈成两个「不存在的因子」。"""
key = "momentum(window=90,direction=lower_is_better)"
out = _invoke(
tools, "create_composite_factor",
{"name": "参数化动量组合", "factors": f"{key}:0.7,volatility_60:0.3"},
)
assert "组合已保存" in out, out
assert key in out and "volatility_60" in out
def test_get_backtest_result(self, tools) -> None:
out = _invoke(tools, "get_backtest_result", {"experiment_id": "EXP-D4"})
assert "总收益" in out and "成交" in out
miss = _invoke(tools, "get_backtest_result", {"experiment_id": "EXP-NONE"})
assert "不存在" in miss
def test_create_experiment_backfills_job(self, tools) -> None:
out = _invoke(tools, "create_experiment", {"job_id": "JOB-D4"})
assert "已归档" in out and "JOB-D4" in out
again = _invoke(tools, "create_experiment", {"job_id": "JOB-D4"})
assert "已归档为" in again # 幂等提示
bad = _invoke(tools, "create_experiment", {"job_id": "JOB-NONE"})
assert "不存在" in bad