2026-09-07 23:02:55 +08:00
|
|
|
|
"""Explicit business tool catalog. This module has no database or provider credentials."""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
|
|
from pydantic import Field
|
|
|
|
|
|
|
2026-09-08 10:06:00 +08:00
|
|
|
|
from ..backtests.contracts import ControlInput, PreviewInput, RerunInput, StartInput
|
2026-09-08 12:43:00 +08:00
|
|
|
|
from ..catalog.contracts import UNIVERSES, CatalogFilters, Scope
|
|
|
|
|
|
from ..research.contracts import (
|
|
|
|
|
|
ChatboxResearchInput,
|
|
|
|
|
|
InputPageArgs,
|
|
|
|
|
|
ResearchInputSelection,
|
|
|
|
|
|
ResearchPreviewInput,
|
|
|
|
|
|
)
|
2026-09-07 23:02:55 +08:00
|
|
|
|
from ..schemas import AlphaFilters, BulkInput, BulkUpdate, Contract, JobInput, ResearchInput, ResearchUpdate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmptyArgs(Contract):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SearchArgs(Contract):
|
|
|
|
|
|
filters: AlphaFilters = Field(default_factory=AlphaFilters)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlphaArgs(Contract):
|
|
|
|
|
|
alpha_id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class JobArgs(Contract):
|
|
|
|
|
|
job_id: str = Field(min_length=1, max_length=100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResearchArgs(AlphaArgs):
|
|
|
|
|
|
changes: ResearchInput
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ResultMetadata(Contract):
|
|
|
|
|
|
source: Literal["local_database"] = "local_database"
|
|
|
|
|
|
observed_at: datetime
|
|
|
|
|
|
nulls: str = "null 表示来源未提供,不等于零"
|
|
|
|
|
|
units: dict[str, str] = Field(
|
|
|
|
|
|
default_factory=lambda: {
|
|
|
|
|
|
"turnover": "比例,0.15 = 15%",
|
|
|
|
|
|
"returns": "比例",
|
|
|
|
|
|
"drawdown": "比例",
|
|
|
|
|
|
"margin": "比例",
|
|
|
|
|
|
"pnl": "供应商原始累计值,未提供货币/规模单位",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 10:06:00 +08:00
|
|
|
|
class BacktestRunArgs(Contract):
|
|
|
|
|
|
run_id: str = Field(min_length=1, max_length=36)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestListArgs(Contract):
|
|
|
|
|
|
limit: int = Field(default=20, ge=1, le=100)
|
|
|
|
|
|
offset: int = Field(default=0, ge=0)
|
|
|
|
|
|
source: str | None = Field(default=None, max_length=100)
|
2026-09-08 12:43:00 +08:00
|
|
|
|
reference: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
research_id: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CatalogSearchArgs(Contract):
|
|
|
|
|
|
filters: CatalogFilters
|
|
|
|
|
|
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CatalogDetailArgs(Contract):
|
|
|
|
|
|
scope: Scope
|
|
|
|
|
|
dataset_id: str = Field(min_length=1, max_length=200)
|
|
|
|
|
|
field_id: str = Field(default="", max_length=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestDraftArgs(Contract):
|
|
|
|
|
|
draft_id: str = Field(min_length=1, max_length=36)
|
|
|
|
|
|
limit: int = Field(default=25, ge=1, le=100)
|
|
|
|
|
|
offset: int = Field(default=0, ge=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlphaSourcesArgs(AlphaArgs):
|
|
|
|
|
|
limit: int = Field(default=25, ge=1, le=100)
|
|
|
|
|
|
offset: int = Field(default=0, ge=0)
|
2026-09-08 10:06:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestResultsArgs(BacktestRunArgs):
|
|
|
|
|
|
limit: int = Field(default=20, ge=1, le=100)
|
|
|
|
|
|
offset: int = Field(default=0, ge=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestPreviewArgs(Contract):
|
|
|
|
|
|
preview_id: str = Field(min_length=1, max_length=36)
|
|
|
|
|
|
limit: int = Field(default=20, ge=1, le=100)
|
|
|
|
|
|
offset: int = Field(default=0, ge=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestControlArgs(BacktestRunArgs):
|
|
|
|
|
|
action: Literal["pause", "resume", "stop", "recover"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BacktestRerunArgs(BacktestRunArgs):
|
|
|
|
|
|
item_ids: list[str] = Field(min_length=1, max_length=100)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 23:02:55 +08:00
|
|
|
|
CATALOG = {
|
2026-09-08 12:43:00 +08:00
|
|
|
|
"get_catalog_scopes": (EmptyArgs, "读取本版支持的研究范围组合,不表示账户已获平台权限。"),
|
|
|
|
|
|
"search_catalog": (
|
|
|
|
|
|
CatalogSearchArgs,
|
|
|
|
|
|
"分页查询本地目录。省略 dataset_id 查询数据集;提供 dataset_id 查询其字段、类型和完整集合版本。无缓存时说明需在数据集页同步,不编造字段。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_catalog_detail": (CatalogDetailArgs, "读取指定范围的数据集或字段详情;field_id 为空表示数据集。"),
|
|
|
|
|
|
"prepare_research_input": (
|
|
|
|
|
|
ResearchInputSelection,
|
|
|
|
|
|
"把明确选出的 1–100 个字段固定为研究输入快照;必须提供已读取的集合版本。只保存本地输入,不启动同步或回测。已有输入引用时直接读取,不重建。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_research_input": (
|
|
|
|
|
|
InputPageArgs,
|
|
|
|
|
|
"分页读取不可变研究输入及原版本字段说明。q 仅搜索字段 ID,类型筛选不改变输入。field_count 是输入总数,total 是筛选匹配数。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"prepare_research_backtest": (
|
|
|
|
|
|
ChatboxResearchInput,
|
|
|
|
|
|
"从研究输入快照构建固定回测预览:提供假设、候选模板(如 rank({price}))、具名字段绑定及真实类型、明确模拟参数。服务端校验归属/类型/范围并替换占位符;不验证算子语义、不自动聚合 VECTOR。来源由服务端标记为当前 chatbox 研究。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_backtest_draft": (
|
|
|
|
|
|
BacktestDraftArgs,
|
|
|
|
|
|
"分页读取已有候选草稿、版本和来源,随后按 draft_id/draft_version 准备预览。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_alpha_sources": (
|
|
|
|
|
|
AlphaSourcesArgs,
|
|
|
|
|
|
"分页读取 Alpha 的全部已保存研究来源、关联回测、聊天会话和输入快照。没有记录不推断来源。",
|
|
|
|
|
|
),
|
2026-09-08 10:06:00 +08:00
|
|
|
|
"get_backtest_capabilities": (
|
|
|
|
|
|
EmptyArgs,
|
|
|
|
|
|
"读取回测输入 schema、支持类型和本地调度配置,不代表平台剩余额度。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"prepare_backtest": (
|
|
|
|
|
|
PreviewInput,
|
|
|
|
|
|
"准备服务端固定回测预览,可用 inline 候选或草稿引用;只保存预览,不提交平台,不需要执行确认。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_backtest_preview": (BacktestPreviewArgs, "分页读取完整固定预览,确认前核对表达式和最终参数。"),
|
|
|
|
|
|
"start_backtest": (
|
|
|
|
|
|
StartInput,
|
|
|
|
|
|
"对已保存预览请求一次用户确认,确认后后台运行全部固定候选,立即返回运行 ID;禁止循环等待。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"list_backtests": (BacktestListArgs, "分页查询回测运行与统计,可按来源筛选。"),
|
|
|
|
|
|
"get_backtest": (BacktestRunArgs, "查询指定运行的真实进度,不循环等待完成。"),
|
|
|
|
|
|
"get_backtest_results": (
|
|
|
|
|
|
BacktestResultsArgs,
|
|
|
|
|
|
"分页读取逐项状态、历史指标和错误;未知结果不能推测为成功。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"control_backtest": (
|
|
|
|
|
|
BacktestControlArgs,
|
|
|
|
|
|
"预览并确认暂停/继续/停止剩余项/找回原任务;不远端取消,不重新提交。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"prepare_backtest_rerun": (
|
|
|
|
|
|
BacktestRerunArgs,
|
|
|
|
|
|
"从明确指定的已结束回测项准备新预览,保留来源;不会自动启动。",
|
|
|
|
|
|
),
|
2026-09-07 23:02:55 +08:00
|
|
|
|
"search_alphas": (
|
|
|
|
|
|
SearchArgs,
|
|
|
|
|
|
"按明确筛选条件查询本地 Alpha。Turnover 0.15 表示 15%;支持分页,禁止把当前页当作全部结果。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"get_alpha_facets": (EmptyArgs, "获取可用地区、类型、状态、标签与本地 Alpha 总数。"),
|
|
|
|
|
|
"get_alpha": (AlphaArgs, "读取指定 Alpha 的表达式、指标、已有检查和本地研究记录。"),
|
|
|
|
|
|
"get_alpha_pnl": (AlphaArgs, "读取指定 Alpha 的 PnL 缓存摘要。缺失缓存时说明情况,不自动刷新。"),
|
|
|
|
|
|
"list_jobs": (EmptyArgs, "查询最近的同步任务,不要循环轮询等待。"),
|
|
|
|
|
|
"get_job_status": (JobArgs, "查询指定任务的状态、目标和错误,不要循环等待任务完成。"),
|
|
|
|
|
|
"update_research": (
|
|
|
|
|
|
ResearchArgs,
|
|
|
|
|
|
"提出指定 Alpha 的本地研究记录修改。仅传要修改的字段,等待用户在界面确认。",
|
|
|
|
|
|
),
|
|
|
|
|
|
"bulk_update_research": (BulkInput, "提出固定 1–100 个 Alpha 的批量标签或研究状态修改,等待用户确认。"),
|
|
|
|
|
|
"create_sync_job": (
|
|
|
|
|
|
JobInput,
|
2026-09-08 10:40:49 +08:00
|
|
|
|
"提出同步或本地自相关任务,等待确认。full_sync 仅同步已提交;待提交必须用 daily_sync 并指定 submission、date_from/date_to(UTC),待提交按创建日、已提交按提交日逐天同步。alpha_refresh/pnl_refresh/self_correlation 使用固定 alpha_ids;自相关缺失 PnL 时自动补取,不触发平台检查。创建后立即返回任务 ID。",
|
2026-09-07 23:02:55 +08:00
|
|
|
|
),
|
|
|
|
|
|
"cancel_job": (JobArgs, "提出取消指定同步任务,等待用户确认。"),
|
|
|
|
|
|
"retry_job": (JobArgs, "提出重试指定失败或暂停的同步任务,等待用户确认。"),
|
|
|
|
|
|
}
|
2026-09-08 10:06:00 +08:00
|
|
|
|
WRITES = {
|
|
|
|
|
|
"update_research",
|
|
|
|
|
|
"bulk_update_research",
|
|
|
|
|
|
"create_sync_job",
|
|
|
|
|
|
"cancel_job",
|
|
|
|
|
|
"retry_job",
|
|
|
|
|
|
"start_backtest",
|
|
|
|
|
|
"control_backtest",
|
|
|
|
|
|
}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bounded(value):
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
return value if len(value) <= 2000 else value[:2000] + "…(已截断)"
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [bounded(v) for v in value[:100]]
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
return {k: bounded(v) for k, v in list(value.items())[:100]}
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def read_tool(business, name, args):
|
|
|
|
|
|
from datetime import timezone
|
|
|
|
|
|
|
2026-09-08 12:43:00 +08:00
|
|
|
|
if name == "get_catalog_scopes":
|
|
|
|
|
|
data = {"universes": UNIVERSES, "instrument_type": "EQUITY", "delays": [0, 1]}
|
|
|
|
|
|
elif name == "search_catalog":
|
|
|
|
|
|
data = await business.catalog.search(args.filters, args.dataset_id)
|
|
|
|
|
|
data.update(
|
|
|
|
|
|
scope=args.filters.model_dump(include=set(Scope.model_fields)), dataset_id=args.dataset_id
|
|
|
|
|
|
)
|
|
|
|
|
|
elif name == "get_catalog_detail":
|
|
|
|
|
|
data = await business.catalog.detail(args.scope, args.dataset_id, args.field_id)
|
|
|
|
|
|
# Saved notes are not required for selection; unsaved drafts never cross this interface.
|
|
|
|
|
|
data.pop("research", None)
|
|
|
|
|
|
elif name == "prepare_research_input":
|
|
|
|
|
|
data = await business.research_builder.select_input(args)
|
|
|
|
|
|
elif name == "get_research_input":
|
|
|
|
|
|
data = await business.research_builder.input_page(**args.model_dump())
|
|
|
|
|
|
elif name == "prepare_research_backtest":
|
|
|
|
|
|
data = await business.research_builder.prepare(ResearchPreviewInput(**args.model_dump()))
|
|
|
|
|
|
elif name == "get_backtest_draft":
|
|
|
|
|
|
data = await business.backtests.draft(args.draft_id)
|
|
|
|
|
|
candidates = data.pop("candidates")
|
|
|
|
|
|
data.update(
|
|
|
|
|
|
items=candidates[args.offset : args.offset + args.limit],
|
|
|
|
|
|
total=len(candidates),
|
|
|
|
|
|
limit=args.limit,
|
|
|
|
|
|
offset=args.offset,
|
|
|
|
|
|
has_more=args.offset + args.limit < len(candidates),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif name == "get_alpha_sources":
|
|
|
|
|
|
data = await business.get_alpha_sources(**args.model_dump())
|
|
|
|
|
|
elif name == "get_backtest_capabilities":
|
2026-09-08 10:06:00 +08:00
|
|
|
|
data = await business.backtests.capabilities()
|
|
|
|
|
|
elif name == "prepare_backtest":
|
|
|
|
|
|
data = await business.backtests.preview(args)
|
|
|
|
|
|
elif name == "get_backtest_preview":
|
|
|
|
|
|
data = await business.backtests.get_preview(**args.model_dump())
|
|
|
|
|
|
elif name == "list_backtests":
|
|
|
|
|
|
data = await business.backtests.runs(**args.model_dump())
|
|
|
|
|
|
elif name == "get_backtest":
|
|
|
|
|
|
data = await business.backtests.run(args.run_id)
|
|
|
|
|
|
elif name == "get_backtest_results":
|
|
|
|
|
|
data = await business.backtests.results(**args.model_dump())
|
|
|
|
|
|
# The complete historical response remains available through the business endpoint.
|
|
|
|
|
|
for item in data["items"]:
|
|
|
|
|
|
if item["result"]:
|
|
|
|
|
|
snapshot = item["result"].pop("snapshot")
|
|
|
|
|
|
item["result"].update({k: snapshot.get(k) for k in ("is", "os", "checks", "dateCreated")})
|
|
|
|
|
|
elif name == "prepare_backtest_rerun":
|
|
|
|
|
|
data = await business.backtests.rerun(args.run_id, RerunInput(item_ids=args.item_ids))
|
|
|
|
|
|
elif name == "search_alphas":
|
2026-09-07 23:02:55 +08:00
|
|
|
|
data = await business.search_alphas(args.filters)
|
|
|
|
|
|
data["filters"] = args.filters.model_dump(mode="json")
|
|
|
|
|
|
elif name == "get_alpha_pnl":
|
|
|
|
|
|
data = await business.get_alpha_pnl(args.alpha_id)
|
|
|
|
|
|
points = data.pop("points")
|
|
|
|
|
|
data.update(
|
|
|
|
|
|
alpha_id=args.alpha_id,
|
|
|
|
|
|
count=len(points),
|
|
|
|
|
|
first=points[0] if points else None,
|
|
|
|
|
|
last=points[-1] if points else None,
|
|
|
|
|
|
null_count=sum(p["value"] is None for p in points),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif name in ("get_alpha", "get_job_status"):
|
|
|
|
|
|
data = await getattr(business, name)(*args.model_dump().values())
|
|
|
|
|
|
else:
|
|
|
|
|
|
data = await getattr(business, name)()
|
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
|
data = {"items": data[:20]}
|
|
|
|
|
|
data["_meta"] = ResultMetadata(observed_at=datetime.now(timezone.utc)).model_dump(mode="json")
|
|
|
|
|
|
return bounded(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def preview_tool(business, name, args):
|
2026-09-08 10:06:00 +08:00
|
|
|
|
if name == "start_backtest":
|
|
|
|
|
|
return {"backtest": await business.backtests.get_preview(args.preview_id)}
|
|
|
|
|
|
if name == "control_backtest":
|
|
|
|
|
|
return {"backtest_run": await business.backtests.run(args.run_id), "action": args.action}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
if name in ("update_research", "bulk_update_research"):
|
|
|
|
|
|
ids = [args.alpha_id] if name == "update_research" else args.alpha_ids
|
|
|
|
|
|
targets, versions = [], {}
|
|
|
|
|
|
for alpha_id in ids:
|
|
|
|
|
|
detail = await business.get_alpha(alpha_id)
|
|
|
|
|
|
before = detail["research"]
|
|
|
|
|
|
versions[alpha_id] = before["version"]
|
|
|
|
|
|
if name == "update_research":
|
|
|
|
|
|
after = {**before, **args.changes.model_dump(exclude_unset=True)}
|
|
|
|
|
|
else:
|
|
|
|
|
|
after = {
|
|
|
|
|
|
**before,
|
|
|
|
|
|
"tags": sorted((set(before["tags"]) | set(args.add_tags)) - set(args.remove_tags)),
|
|
|
|
|
|
}
|
|
|
|
|
|
if args.state:
|
|
|
|
|
|
after["state"] = args.state
|
|
|
|
|
|
# Preview and execution use the same validation rules.
|
|
|
|
|
|
ResearchInput.model_validate({k: after[k] for k in ("note", "tags", "favorite", "state")})
|
|
|
|
|
|
targets.append({"alpha_id": alpha_id, "before": before, "after": after})
|
|
|
|
|
|
return {"targets": targets, "versions": versions}
|
|
|
|
|
|
if name in ("cancel_job", "retry_job"):
|
|
|
|
|
|
return {"job": await business.get_job_status(args.job_id)}
|
|
|
|
|
|
return {"operation": args.model_dump(mode="json")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def execute_tool(business, name, args, preview):
|
2026-09-08 10:06:00 +08:00
|
|
|
|
if name == "start_backtest":
|
|
|
|
|
|
current = await business.backtests.get_preview(args.preview_id)
|
|
|
|
|
|
if current["digest"] != preview["backtest"]["digest"] or current["version"] != args.version:
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(409, "回测预览不匹配,请重新确认")
|
|
|
|
|
|
return await business.backtests.start(args)
|
|
|
|
|
|
if name == "control_backtest":
|
|
|
|
|
|
return await business.backtests.control(
|
|
|
|
|
|
args.run_id, ControlInput(action=args.action, version=preview["backtest_run"]["version"])
|
|
|
|
|
|
)
|
2026-09-07 23:02:55 +08:00
|
|
|
|
if name == "update_research":
|
|
|
|
|
|
body = ResearchUpdate(
|
|
|
|
|
|
**args.changes.model_dump(exclude_unset=True), version=preview["versions"][args.alpha_id]
|
|
|
|
|
|
)
|
|
|
|
|
|
return await business.update_research(args.alpha_id, body)
|
|
|
|
|
|
if name == "bulk_update_research":
|
|
|
|
|
|
return await business.bulk_update_research(
|
|
|
|
|
|
BulkUpdate(**args.model_dump(), versions=preview["versions"])
|
|
|
|
|
|
)
|
|
|
|
|
|
if name == "create_sync_job":
|
|
|
|
|
|
return await business.create_sync_job(args)
|
|
|
|
|
|
current = await business.get_job_status(args.job_id)
|
|
|
|
|
|
if current["updated_at"] != preview["job"]["updated_at"] or current["status"] != preview["job"]["status"]:
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(409, "任务状态已变化,请重新确认操作")
|
|
|
|
|
|
return await getattr(business, name)(args.job_id)
|