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