diff --git a/backend/app/agent/tools_impl.py b/backend/app/agent/tools_impl.py index 87420d9..a447a23 100644 --- a/backend/app/agent/tools_impl.py +++ b/backend/app/agent/tools_impl.py @@ -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: diff --git a/backend/tests/test_agent_v3_tools.py b/backend/tests/test_agent_v3_tools.py new file mode 100644 index 0000000..9356376 --- /dev/null +++ b/backend/tests/test_agent_v3_tools.py @@ -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