字段库(本次新增的表与接口): - `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。
669 lines
28 KiB
Python
669 lines
28 KiB
Python
"""受控工具集实现(AGENT.md §28):Agent 只能调用这里的白名单工具。
|
||
|
||
全部工具经 Job/Experiment 链路或只读查询执行:
|
||
- 不提供 shell / 任意代码执行 / 修改配置与凭证 / 删除数据
|
||
- 任何研究都会产出 Experiment 归档(可复现)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import date
|
||
|
||
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,
|
||
ResearchSpec,
|
||
UniverseSpec,
|
||
)
|
||
from app.domain.entities.selection import SelectionQuery
|
||
from app.domain.entities.signal import SignalRules
|
||
from app.domain.entities.strategy import SelectionStrategy
|
||
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:
|
||
return date.fromisoformat(text)
|
||
|
||
|
||
def _split_factor_list(raw: str) -> list[str]:
|
||
"""按逗号切因子列表,但**不切参数化因子键里的逗号**。
|
||
|
||
参数化因子的名字把参数写全了(`momentum(window=90,direction=higher_is_better)`),
|
||
直接 `.split(",")` 会把它劈成「momentum(window=90」和「direction=…):0.7」两段,
|
||
模型与用户只会收到「因子不存在」这种看不懂的错。括号深度感知的切分让两种写法都能用:
|
||
|
||
momentum_60,volatility_60
|
||
momentum(window=90,direction=lower_is_better),volatility_60
|
||
"""
|
||
out: list[str] = []
|
||
depth = 0
|
||
buf: list[str] = []
|
||
for ch in raw:
|
||
if ch == "(":
|
||
depth += 1
|
||
elif ch == ")":
|
||
depth = max(0, depth - 1)
|
||
if ch == "," and depth == 0:
|
||
out.append("".join(buf).strip())
|
||
buf = []
|
||
else:
|
||
buf.append(ch)
|
||
out.append("".join(buf).strip())
|
||
return [x for x in out if x]
|
||
|
||
|
||
def _split_name_weight(part: str) -> tuple[str, str]:
|
||
"""把 `name:weight` 按**括号外**的第一个冒号切开(参数化键里的 `=`/`,` 不受影响)。"""
|
||
depth = 0
|
||
for i, ch in enumerate(part):
|
||
if ch == "(":
|
||
depth += 1
|
||
elif ch == ")":
|
||
depth = max(0, depth - 1)
|
||
elif ch == ":" and depth == 0:
|
||
return part[:i].strip(), part[i + 1 :].strip()
|
||
return part.strip(), ""
|
||
|
||
|
||
def _pick(mapping: dict, key: str, default=None):
|
||
val = mapping.get(key, default)
|
||
if isinstance(val, str):
|
||
val = val.strip()
|
||
if val == "":
|
||
return default
|
||
return val
|
||
|
||
|
||
def _job_result_json(job, *, session_factory, experiment_repo_factory) -> str | None:
|
||
"""取 Job 的完整结果 JSON。
|
||
|
||
2026-09 起完整结果只在 experiment 存一份(`job.result_json` 为 None),故先按
|
||
`job.experiment_id` 回读归档;归档不存在 / 老记录再回退 `job.result_json`。
|
||
"""
|
||
if job.experiment_id:
|
||
try:
|
||
with session_factory() as session:
|
||
exp = experiment_repo_factory(session).get(job.experiment_id)
|
||
if exp is not None:
|
||
return exp.result_json
|
||
except Exception: # noqa: BLE001 —— 回读失败则回退 job 副本,不阻断工具
|
||
pass
|
||
return job.result_json
|
||
|
||
|
||
def build_tools(factories: dict | None = None) -> list[Tool]:
|
||
facts = factories or default_factories()
|
||
session_factory = facts["session_factory"]
|
||
stock_repo_f = facts["stock_repo_factory"]
|
||
daily_repo_f = facts["daily_repo_factory"]
|
||
exp_repo_f = facts["experiment_repo_factory"]
|
||
|
||
def search_stocks(args: dict) -> str:
|
||
q = str(_pick(args, "q", "") or "").upper()
|
||
with session_factory() as session:
|
||
stocks = stock_repo_f(session).list()
|
||
rows = [
|
||
s for s in stocks if (not q) or q in s.symbol.upper() or q in (s.name or "").upper()
|
||
][:15]
|
||
if not rows:
|
||
return "未找到匹配股票"
|
||
return "\n".join(
|
||
f"{s.symbol} {s.name} 行业={s.industry or '-'} 上市={s.list_date}" for s in rows
|
||
)
|
||
|
||
def get_market_data(args: dict) -> str:
|
||
symbol = str(_pick(args, "symbol", "")).upper()
|
||
start = _day(str(_pick(args, "start", "2024-01-01")))
|
||
end = _day(str(_pick(args, "end", date.today().isoformat())))
|
||
with session_factory() as session:
|
||
bars = daily_repo_f(session).get_range(symbol, start, end)
|
||
if not bars:
|
||
return f"{symbol} 在 {start}~{end} 无日线数据(可能未同步)"
|
||
head, tail = bars[0], bars[-1]
|
||
last = "\n".join(f"{b.trade_date} close={b.close}" for b in bars[-8:])
|
||
change = float(tail.close) / float(head.close) - 1 if head.close and tail.close else None
|
||
return (
|
||
f"{symbol} {start}~{end} 共 {len(bars)} 根日线;"
|
||
f"区间 {head.trade_date}→{tail.trade_date} 收盘 {head.close}→{tail.close}"
|
||
f"(涨跌 {change * 100:.2f}% 若数据完整);最近 8 根:\n{last}"
|
||
)
|
||
|
||
def _run_spec(spec: ResearchSpec, desc: str) -> str:
|
||
job = submit_and_run(spec, factories=facts)
|
||
if job.status != "success":
|
||
return f"{desc} 执行失败:{job.error}"
|
||
result_json = _job_result_json(
|
||
job, session_factory=session_factory, experiment_repo_factory=exp_repo_f
|
||
)
|
||
if spec.type == "backtest":
|
||
result = BacktestResult.model_validate_json(result_json or "{}")
|
||
s = result.summary
|
||
return (
|
||
f"回测完成(Experiment {job.experiment_id},代码版本 {_code_version(job, exp_repo_f)})。"
|
||
f"总收益 {s.total_return_pct:.2f}%,年化 {s.annual_return_pct:.2f}%,"
|
||
f"Sharpe {s.sharpe:.2f},最大回撤 {s.max_drawdown_pct:.2f}%,"
|
||
f"交易 {s.total_trades} 笔,平均换手 {s.avg_turnover_pct:.1f}%。"
|
||
f"未建模约束 {len(result.unimplemented)} 项(成本/涨跌停近似见实验详情)。"
|
||
)
|
||
report = FactorTestReport.model_validate_json(result_json or "{}")
|
||
qs = ", ".join(f"Q{q.quantile + 1}: {q.return_pct:.2f}%" for q in report.quantile_returns)
|
||
return (
|
||
f"因子测试完成(Experiment {job.experiment_id})。IC {report.ic_mean:.4f},"
|
||
f"RankIC {report.rank_ic_mean:.4f},ICIR {report.icir:.2f},正收益占比 "
|
||
f"{report.positive_ratio_pct:.1f}%,样本 {report.sample_days} 日;分层未来收益 {qs}。"
|
||
f"注意:单因子测试不代表策略有效,需结合稳健性分析。"
|
||
)
|
||
|
||
def test_factor(args: dict) -> str:
|
||
name = str(_pick(args, "name", ""))
|
||
start = _day(str(_pick(args, "start", "2024-01-01")))
|
||
end = _day(str(_pick(args, "end", "2024-12-31")))
|
||
spec = ResearchSpec(
|
||
type="factor_test",
|
||
universe={"exclude_st": True, "min_listing_days": 0},
|
||
factors=[{"name": name, "weight": 1.0}],
|
||
selection={"top_n": 10},
|
||
rebalance="monthly",
|
||
period=(start, end),
|
||
)
|
||
return _run_spec(spec, f"因子 {name} 测试")
|
||
|
||
def run_backtest(args: dict) -> str:
|
||
factor_names = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
|
||
top_n = int(_pick(args, "top_n", 5) or 5)
|
||
rebalance = str(_pick(args, "rebalance", "monthly"))
|
||
exclude_st = bool(_pick(args, "exclude_st", True))
|
||
start = _day(str(_pick(args, "start", "2024-01-01")))
|
||
end = _day(str(_pick(args, "end", "2024-12-31")))
|
||
spec = ResearchSpec(
|
||
type="backtest",
|
||
universe={"exclude_st": exclude_st, "min_listing_days": 0},
|
||
factors=[{"name": n, "weight": 1.0} for n in factor_names],
|
||
selection={"top_n": top_n},
|
||
rebalance=rebalance,
|
||
period=(start, end),
|
||
)
|
||
return _run_spec(spec, "回测")
|
||
|
||
def get_experiment(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} 不存在(可用列表:GET /api/experiments)"
|
||
spec = json.loads(exp.spec_json)
|
||
return (
|
||
f"Experiment {exp.id} [{exp.kind}] 因子={[f['name'] for f in spec.get('factors', [])]} "
|
||
f"区间={spec.get('period')} 调仓={spec.get('rebalance')};摘要:{exp.summary_text or '-'} "
|
||
f"代码版本={exp.code_version or '-'} 创建={exp.created_at}"
|
||
)
|
||
|
||
def compare_experiments(args: dict) -> str:
|
||
ids = [
|
||
x.strip().upper()
|
||
for x in str(_pick(args, "experiment_ids", "")).split(",")
|
||
if x.strip()
|
||
]
|
||
if not ids:
|
||
return "请提供 experiment_ids(逗号分隔)"
|
||
with session_factory() as session:
|
||
repo = exp_repo_f(session)
|
||
rows = [(i, repo.get(i)) for i in ids]
|
||
out = []
|
||
for exp_id, exp in rows:
|
||
if exp is None:
|
||
out.append(f"{exp_id}: 不存在")
|
||
else:
|
||
spec = json.loads(exp.spec_json)
|
||
out.append(
|
||
f"{exp.id}: 因子={[f['name'] for f in spec.get('factors', [])]} "
|
||
f"区间={spec.get('period')} → {exp.summary_text or '-'}"
|
||
)
|
||
return "\n".join(out)
|
||
|
||
def _scope_symbols(raw: str | None) -> list[str]:
|
||
"""白名单(可选):避免全市场长任务拖垮同步对话(全市场可用 Web 页异步)。"""
|
||
if not raw:
|
||
return []
|
||
return [x.strip().upper() for x in raw.split(",") if x.strip()][:60]
|
||
|
||
def screen_stocks(args: dict) -> str:
|
||
factors = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
|
||
top_n = int(_pick(args, "top_n", 10) or 10)
|
||
as_of = _day(str(_pick(args, "as_of", date.today().isoformat())))
|
||
symbols = _scope_symbols(str(_pick(args, "symbols", "") or ""))
|
||
if not symbols:
|
||
return (
|
||
"为避免全市场长任务(>1 分钟),请传 symbols 白名单(≤60,逗号分隔)"
|
||
"或使用 Web 选股页执行全市场选股。"
|
||
)
|
||
query = SelectionQuery(
|
||
universe=UniverseSpec(
|
||
exclude_st=bool(_pick(args, "exclude_st", True)),
|
||
min_listing_days=0,
|
||
symbols=symbols,
|
||
),
|
||
factors=[{"name": f, "weight": 1.0} for f in factors],
|
||
top_n=top_n,
|
||
as_of=as_of,
|
||
)
|
||
with session_factory() as session:
|
||
service = SelectionService(
|
||
stock_repo_f(session), daily_repo_f(session)
|
||
)
|
||
result = service.select(query)
|
||
if not result.candidates:
|
||
return (
|
||
f"{as_of} 无候选(范围 {result.statistics.universe_size} 只,"
|
||
f"可评分 {result.statistics.evaluated})。如需白名单可传 symbols(≤60)。"
|
||
)
|
||
lines = [f"as_of={result.as_of_date} 选出 Top{len(result.candidates)}:"]
|
||
for c in result.candidates:
|
||
vals = ", ".join(f"{k}={v:.4f}" for k, v in c.factor_values.items())
|
||
lines.append(f" #{c.rank} {c.symbol} score={c.score:.4f}({vals})")
|
||
lines.append("入选理由见 explain_selection(selection_id)。")
|
||
return "\n".join(lines)
|
||
|
||
def explain_selection(args: dict) -> str:
|
||
sel_id = str(_pick(args, "selection_id", "")).upper()
|
||
with session_factory() as session:
|
||
repo = SqlAlchemySelectionRepository(session)
|
||
result = repo.get(sel_id)
|
||
if result is None:
|
||
return f"选股记录 {sel_id} 不存在(先通过 Web 选股页或 screen_stocks 生成)"
|
||
out = [f"选股 {sel_id} as_of={result.as_of_date}({result.method},选出 {len(result.candidates)} 只)"]
|
||
for c in result.candidates[:10]:
|
||
reasons = "; ".join(c.selection_reason[:3])
|
||
out.append(f" #{c.rank} {c.symbol} score={c.score:.4f} — {reasons}")
|
||
return "\n".join(out)
|
||
|
||
def generate_signals(args: dict) -> str:
|
||
factors = _split_factor_list(str(_pick(args, "factors", "momentum_60")))
|
||
as_of = _day(str(_pick(args, "as_of", date.today().isoformat())))
|
||
symbols = _scope_symbols(str(_pick(args, "symbols", "") or ""))
|
||
query = SelectionQuery(
|
||
universe=UniverseSpec(
|
||
exclude_st=bool(_pick(args, "exclude_st", True)),
|
||
min_listing_days=0,
|
||
symbols=symbols,
|
||
),
|
||
factors=[{"name": f, "weight": 1.0} for f in factors],
|
||
top_n=int(_pick(args, "top_n", 50) or 50),
|
||
as_of=as_of,
|
||
)
|
||
rules = SignalRules(
|
||
buy_rank_threshold=int(_pick(args, "buy_rank", 20) or 20),
|
||
sell_rank_threshold=int(_pick(args, "sell_rank", 50) or 50),
|
||
)
|
||
with session_factory() as session:
|
||
res = SignalService(stock_repo_f(session), daily_repo_f(session)).signal(query, rules)
|
||
out = [
|
||
f"信号 as_of={res.as_of_date}: BUY {res.statistics.buy} / WATCH {res.statistics.watch} / "
|
||
f"SELL {res.statistics.sell}(前 8 条)"
|
||
]
|
||
for e in res.events[:8]:
|
||
out.append(f" {e.signal_type} {e.symbol} score={e.score:.4f} — {e.trigger_reason[0] if e.trigger_reason else ''}")
|
||
return "\n".join(out)
|
||
|
||
def create_strategy(args: dict) -> str:
|
||
name = str(_pick(args, "name", ""))
|
||
if not name:
|
||
return "请提供 name"
|
||
factors = [
|
||
{"name": x, "weight": 1.0}
|
||
for x in _split_factor_list(str(_pick(args, "factors", "momentum_60")))
|
||
]
|
||
if not factors:
|
||
return "请提供至少一个 factors(逗号分隔)"
|
||
description = str(_pick(args, "description", "") or "")
|
||
# 选股策略只存「选股条件组合」:股票池 + 因子(+ 可选条件)。
|
||
# top_n / rebalance 等回测执行参数已移到「回测组合」,Agent 不再在此指定。
|
||
st = SelectionStrategy(
|
||
name=name,
|
||
description=description,
|
||
universe=UniverseSpec(
|
||
exclude_st=bool(_pick(args, "exclude_st", True)), min_listing_days=0
|
||
),
|
||
factors=factors,
|
||
)
|
||
from app.application.services.job_executor import new_id
|
||
|
||
with session_factory() as session:
|
||
saved = SqlAlchemyStrategyRepository(session).save(
|
||
st.model_copy(update={"id": new_id("STG")})
|
||
)
|
||
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", ""))
|
||
# 先问引擎:目录里有没有这行是「管理」问题,引擎算不算得出来才是「能不能用」。
|
||
# 参数化因子(momentum(window=90,direction=…))经常还没进目录就被引用,也能算。
|
||
try:
|
||
defn, _fn = get_factor(name)
|
||
except FactorError as exc:
|
||
return f"因子不可用:{exc}"
|
||
with session_factory() as session:
|
||
row = SqlAlchemyFactorRepository(session).get(name)
|
||
params = ",".join(f"{k}={v}" for k, v in defn.params.items())
|
||
head = f"{defn.label}({defn.name})" if defn.label else defn.name
|
||
return (
|
||
f"{head}:{defn.description}\n公式:{defn.formula}\n方向:"
|
||
f"{'越高越好' if defn.direction == 'higher_is_better' else '越低越好'}"
|
||
f"(lookback {defn.lookback},输入 {defn.requires})\n"
|
||
f"参数:{params or '(无:内置实例名固定口径)'}\n"
|
||
f"来源:{'代码注册表内置' if defn.source == 'builtin' else '目录里的参数化实例'}"
|
||
f"{';在目录中已停用(仍可被引用)' if row is not None and not row.enabled else ''}\n"
|
||
f"简介:{defn.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;"
|
||
"参数化因子写成 momentum(window=90,direction=lower_is_better):0.7)"
|
||
)
|
||
comps: list[CompositeComponent] = []
|
||
for part in _split_factor_list(raw):
|
||
fname, weight_text = _split_name_weight(part)
|
||
weight = float(weight_text) if weight_text 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} 不存在"
|
||
# 顺序要紧:新形态记录结果只在 experiment 侧(job.result_json 为 None),
|
||
# 先判「已归档」,否则成功 Job 会被误判为「无结果可归档」
|
||
if job.experiment_id:
|
||
return f"Job {job_id} 已归档为 {job.experiment_id}"
|
||
if job.status != "success" or not job.result_json:
|
||
return f"Job {job_id} 未成功(无结果可归档)"
|
||
# 走到这里必然是老形态记录(结果仍在 job 侧,无 experiment 关联)
|
||
result_json = job.result_json
|
||
exp_repo = exp_repo_f(session)
|
||
summary = None
|
||
try:
|
||
if job.kind == "backtest":
|
||
r = BacktestResult.model_validate_json(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=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",
|
||
"按代码或名称搜索股票,返回基础信息(只读)",
|
||
{
|
||
"type": "object",
|
||
"properties": {"q": {"type": "string", "description": "代码或名称关键字"}},
|
||
},
|
||
search_stocks,
|
||
),
|
||
Tool(
|
||
"get_market_data",
|
||
"读取一只股票一段区间的日线行情摘要(只读,不复权)",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"symbol": {"type": "string", "description": "如 600519.SH"},
|
||
"start": {"type": "string", "description": "YYYY-MM-DD"},
|
||
"end": {"type": "string", "description": "YYYY-MM-DD"},
|
||
},
|
||
"required": ["symbol"],
|
||
},
|
||
get_market_data,
|
||
),
|
||
Tool(
|
||
"test_factor",
|
||
"对单个因子做 IC/RankIC/分层测试并归档 Experiment",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"description": "因子名(momentum_60 / volatility_20 等)",
|
||
},
|
||
"start": {"type": "string"},
|
||
"end": {"type": "string"},
|
||
},
|
||
"required": ["name"],
|
||
},
|
||
test_factor,
|
||
),
|
||
Tool(
|
||
"run_backtest",
|
||
"运行 TopK 低频回测并归档 Experiment(成本/涨跌停近似建模)",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"factors": {"type": "string", "description": "逗号分隔的因子名"},
|
||
"top_n": {"type": "integer"},
|
||
"rebalance": {"type": "string", "enum": ["monthly", "weekly"]},
|
||
"exclude_st": {"type": "boolean"},
|
||
"start": {"type": "string"},
|
||
"end": {"type": "string"},
|
||
},
|
||
},
|
||
run_backtest,
|
||
),
|
||
Tool(
|
||
"get_experiment",
|
||
"读取已归档实验的摘要",
|
||
{
|
||
"type": "object",
|
||
"properties": {"experiment_id": {"type": "string"}},
|
||
"required": ["experiment_id"],
|
||
},
|
||
get_experiment,
|
||
),
|
||
Tool(
|
||
"compare_experiments",
|
||
"对比多个实验(因子/区间/收益摘要)",
|
||
{
|
||
"type": "object",
|
||
"properties": {"experiment_ids": {"type": "string"}},
|
||
"required": ["experiment_ids"],
|
||
},
|
||
compare_experiments,
|
||
),
|
||
Tool(
|
||
"screen_stocks",
|
||
"按因子评分筛选股票(TopN;传 symbols 白名单避免全市场长任务)",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"factors": {"type": "string", "description": "逗号分隔因子名"},
|
||
"top_n": {"type": "integer"},
|
||
"as_of": {"type": "string", "description": "YYYY-MM-DD"},
|
||
"symbols": {"type": "string", "description": "逗号分隔白名单(可选,≤60)"},
|
||
},
|
||
},
|
||
screen_stocks,
|
||
),
|
||
Tool(
|
||
"explain_selection",
|
||
"解释一次选股结果:为什么选这些股票(含因子值与理由)",
|
||
{
|
||
"type": "object",
|
||
"properties": {"selection_id": {"type": "string"}},
|
||
"required": ["selection_id"],
|
||
},
|
||
explain_selection,
|
||
),
|
||
Tool(
|
||
"generate_signals",
|
||
"基于选股评分+趋势生成 BUY/WATCH/SELL 信号",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"factors": {"type": "string"},
|
||
"as_of": {"type": "string"},
|
||
"symbols": {"type": "string", "description": "白名单(可选)"},
|
||
"buy_rank": {"type": "integer"},
|
||
"sell_rank": {"type": "integer"},
|
||
},
|
||
},
|
||
generate_signals,
|
||
),
|
||
Tool(
|
||
"create_strategy",
|
||
"创建/保存一个命名策略(可随后展开为回测)",
|
||
{
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"description": {"type": "string"},
|
||
"factors": {"type": "string"},
|
||
"top_n": {"type": "integer"},
|
||
"rebalance": {"type": "string", "enum": ["monthly", "weekly"]},
|
||
},
|
||
"required": ["name", "factors"],
|
||
},
|
||
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:
|
||
exp = exp_repo_f(session).get(job.experiment_id or "")
|
||
return exp.code_version or "-" if exp else "-"
|
||
except Exception: # noqa: BLE001
|
||
return "-"
|