feat(agent): D4 Agent 补齐至 v3 §25(10 → 14 工具)

- inspect_factor:因子目录元数据(公式/方向/lookback/输入列)
- create_composite_factor:解析 name:weight 组件并保存(方向由注册表填充,未注册 400 语义)
- get_backtest_result:回测 Experiment 详细结果(收益/回撤/交易/意图与成交统计)
- create_experiment:成功 Job 兜底归档为 Experiment(幂等提示)
- 白名单工具总数 14(= v3 §25 清单 + get_market_data);tests/test_agent_v3_tools.py
  5 例(数量/各工具行为/幂等);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 07:40:45 +08:00
parent 7c268e43df
commit ed54096331
2 changed files with 298 additions and 1 deletions
+166 -1
View File
@@ -14,6 +14,7 @@ from app.agent.tools import Tool
from app.application.services.job_executor import default_factories, submit_and_run
from app.application.services.selection_service import SelectionService
from app.application.services.signal_service import SignalService
from app.domain.entities.composite import CompositeComponent, CompositeDefinition
from app.domain.entities.research import (
BacktestResult,
FactorTestReport,
@@ -23,12 +24,19 @@ from app.domain.entities.research import (
from app.domain.entities.selection import SelectionQuery
from app.domain.entities.signal import SignalRules
from app.domain.entities.strategy import StrategyDefinition
from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import (
SqlAlchemyCompositeRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import (
SqlAlchemyFactorRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.selection_impl import (
SqlAlchemySelectionRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.strategy_impl import (
SqlAlchemyStrategyRepository,
)
from app.quant.factors import FactorError, get_factor
def _day(text: str) -> date:
@@ -286,6 +294,120 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
session.commit()
return f"策略已保存:{saved.id} {saved.name}(factors={[f.name for f in saved.factors]})"
def inspect_factor(args: dict) -> str:
name = str(_pick(args, "name", ""))
with session_factory() as session:
row = SqlAlchemyFactorRepository(session).get(name)
if row is None:
return f"因子 {name} 不在目录(可用列表:GET /api/factors)"
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}"
)
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)"
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
if not fname:
continue
try:
defn, _fn = get_factor(fname)
except FactorError as exc:
return f"无法创建:{exc}"
comps.append(CompositeComponent(name=fname, weight=weight, direction=defn.direction))
if not comps:
return "未解析到任何因子组件"
from app.application.services.job_executor import new_id
cf = CompositeDefinition(
name=name, description=str(_pick(args, "description", "") or ""), components=comps
)
with session_factory() as session:
saved = SqlAlchemyCompositeRepository(session).save(
cf.model_copy(update={"id": new_id("CF")})
)
session.commit()
return (
f"组合已保存:{saved.id} {saved.name}("
+ ", ".join(f"{c.name}:{c.weight}" for c in saved.components)
+ ")"
)
def get_backtest_result(args: dict) -> str:
exp_id = str(_pick(args, "experiment_id", "")).upper()
with session_factory() as session:
exp = exp_repo_f(session).get(exp_id)
if exp is None:
return f"Experiment {exp_id} 不存在"
try:
result = BacktestResult.model_validate_json(exp.result_json)
except Exception: # noqa: BLE001
return f"{exp_id} 不是回测结果"
sm = result.summary
return (
f"回测 {exp_id} {sm.start}~{sm.end}:总收益 {sm.total_return_pct:.2f}%,"
f"年化 {sm.annual_return_pct:.2f}%,Sharpe {sm.sharpe:.2f},"
f"最大回撤 {sm.max_drawdown_pct:.2f}%,期末 {sm.final_equity:,.0f} 元;"
f"交易 {sm.total_trades} 笔胜率 {sm.win_rate_pct:.1f}%;"
f"选股记录 {len(result.selection_history)} / 信号 {len(result.signal_history)} / "
f"成交 {len(result.fills)};未建模 {len(result.unimplemented)} 项"
)
def create_experiment(args: dict) -> str:
"""把成功 Job 兜底归档为 Experiment(研究工具已自动归档;本工具用于补档)。"""
job_id = str(_pick(args, "job_id", "")).upper()
if not job_id:
return "请提供 job_id"
from app.application.services.job_executor import new_id
from app.domain.entities.research import ExperimentRecord
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyJobRepository,
)
with session_factory() as session:
job = SqlAlchemyJobRepository(session).get(job_id)
if job is None:
return f"Job {job_id} 不存在"
if job.status != "success" or not job.result_json:
return f"Job {job_id} 未成功(无结果可归档)"
if job.experiment_id:
return f"Job {job_id} 已归档为 {job.experiment_id}"
exp_repo = exp_repo_f(session)
summary = None
try:
if job.kind == "backtest":
r = BacktestResult.model_validate_json(job.result_json)
summary = (
f"总收益 {r.summary.total_return_pct:.2f}% · 年化 "
f"{r.summary.annual_return_pct:.2f}% · 回撤 {r.summary.max_drawdown_pct:.2f}%"
)
except Exception: # noqa: BLE001
pass
exp = ExperimentRecord(
id=new_id("EXP"),
kind=job.kind,
spec_json=job.spec_json,
result_json=job.result_json,
summary_text=summary,
job_id=job.id,
created_at=job.created_at,
)
exp_repo.save(exp)
job.experiment_id = exp.id
SqlAlchemyJobRepository(session).update(job)
session.commit()
return f"已归档:{exp.id}(Job {job_id} → Experiment)"
return [
Tool(
"search_stocks",
@@ -418,9 +540,52 @@ def build_tools(factories: dict | None = None) -> list[Tool]:
},
create_strategy,
),
Tool(
"inspect_factor",
"查看因子目录元数据(公式/方向/lookback/输入列)",
{
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
inspect_factor,
),
Tool(
"create_composite_factor",
"创建并保存多因子组合(factors 格式:momentum_60:0.7,volatility_60:0.3)",
{
"type": "object",
"properties": {
"name": {"type": "string"},
"factors": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name", "factors"],
},
create_composite_factor,
),
Tool(
"get_backtest_result",
"读取回测 Experiment 的详细结果(收益/回撤/交易/意图与成交统计)",
{
"type": "object",
"properties": {"experiment_id": {"type": "string"}},
"required": ["experiment_id"],
},
get_backtest_result,
),
Tool(
"create_experiment",
"把成功 Job 兜底归档为 Experiment(补档;研究工具已自动归档)",
{
"type": "object",
"properties": {"job_id": {"type": "string"}},
"required": ["job_id"],
},
create_experiment,
),
]
def _code_version(job, exp_repo_f) -> str:
try:
with default_factories()["session_factory"]() as session:
+132
View File
@@ -0,0 +1,132 @@
"""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_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_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