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