feat: expose local self-correlation tools through MCP
Deploy production / deploy (push) Successful in 57s

This commit is contained in:
yuxuanhui
2026-09-10 09:42:24 +08:00
parent 53b01eb770
commit d4c33d0124
7 changed files with 187 additions and 9 deletions
@@ -0,0 +1,11 @@
# 补充 MCP 自相关工具
Type: task
Status: ready-for-agent
实现 spec.md 中的工具及验证。
## Comments
- 已实现工具注册、输入约束、共享业务调用、权限与队列唤醒,并补充能力说明和接入文档。
- 官方 MCP ClientSession 经 HTTP 完成发起、进度及结果读取;11 项 MCP 测试通过,包括缓存补取、活动任务复用、审计、只读权限及异常输入。
+7
View File
@@ -0,0 +1,7 @@
# MCP 本地自相关
新增 check_self_correlation(research:refresh)和 get_self_correlation(research:read),并扩展 get_refresh_job 查询 self_correlation 任务。复用网页 Business 和 Runner,不新建算法,不调用平台提交检查。
发起输入为 1–100 个已导入 Alpha ID,去重排序后复用活动任务;缺失目标整批拒绝并返回 affected_items。返回 job_id 后后台补取缺失 PnL 并计算,提交事务后唤醒队列。读取明确区分无缓存、可用及待重算,不隐式请求平台。沿用 MCP 审计、错误契约和权限过滤,无迁移或新增权限。
验收:官方 SDK 工具发现及调用往返、只读权限拒绝发起、参数与缺失目标校验、异步唤醒、去重、缺失 PnL 落库、结果与 stale 读取、任务审计。更新接入文档与能力声明。
+4 -2
View File
@@ -25,7 +25,9 @@ TOOLS = {
"search_catalog": (c.CatalogSearch, "catalog", "research:read", "分页查询指定范围的数据集或字段元数据;无缓存不等于无数据,不隐式刷新。"), "search_catalog": (c.CatalogSearch, "catalog", "research:read", "分页查询指定范围的数据集或字段元数据;无缓存不等于无数据,不隐式刷新。"),
"get_research_metadata": (c.Metadata, "metadata", "research:read", "读取范围、设置快照、算子定义或字段可用性;未知不认定通过。"), "get_research_metadata": (c.Metadata, "metadata", "research:read", "读取范围、设置快照、算子定义或字段可用性;未知不认定通过。"),
"refresh_research_data": (c.Refresh, "refresh", "research:refresh", "显式刷新目录、算子、设置、字段可用性或 PnL;不会创建模拟。任务返回 job_id。"), "refresh_research_data": (c.Refresh, "refresh", "research:refresh", "显式刷新目录、算子、设置、字段可用性或 PnL;不会创建模拟。任务返回 job_id。"),
"get_refresh_job": (c.JobReference, "refresh_job", "research:read", "查询研究刷新任务的状态和产物引用。"), "get_refresh_job": (c.JobReference, "refresh_job", "research:read", "查询研究刷新或本地自相关任务的状态、进度与分页错误。"),
"check_self_correlation": (c.SelfCorrelationCheck, "check_self_correlation", "research:refresh", "对 1–100 个已导入 Alpha 发起本地自相关检查,返回 job_id。与本地同地区已提交 Alpha 比较,排除自身;优先用缓存,缺失 PnL 自动补取。需先同步已提交 Alpha;不调用平台提交检查。用 get_refresh_job 查进度、get_self_correlation 读结果。"),
"get_self_correlation": (c.SelfCorrelationReference, "self_correlation", "research:read", "读取指定 Alpha 最新的本地自相关缓存,包括最大相关系数、样本覆盖与 stale 状态;无缓存不自动检查,结果不等同于平台提交资格。"),
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"), "search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"), "submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"), "get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
@@ -64,7 +66,7 @@ class MCPResearchServer:
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations( inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
readOnlyHint=scope == "research:read", destructiveHint=method == "control", readOnlyHint=scope == "research:read", destructiveHint=method == "control",
idempotentHint=method in {"submit", "control"} or scope == "research:read", idempotentHint=method in {"submit", "control"} or scope == "research:read",
openWorldHint=method in {"refresh", "submit", "metadata"})) openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation"}))
for name, (schema, method, scope, description) in TOOLS.items() for name, (schema, method, scope, description) in TOOLS.items()
if scope in principal.scopes and "research:read" in principal.scopes]) if scope in principal.scopes and "research:read" in principal.scopes])
+9
View File
@@ -11,6 +11,7 @@ from ..schemas import Contract
Identifier = Annotated[str, Field(min_length=1, max_length=100)] Identifier = Annotated[str, Field(min_length=1, max_length=100)]
RunId = Annotated[str, Field(min_length=1, max_length=36)] RunId = Annotated[str, Field(min_length=1, max_length=36)]
AlphaId = Annotated[str, Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")]
class Empty(Contract): class Empty(Contract):
@@ -124,6 +125,14 @@ class JobReference(Page):
job_id: RunId job_id: RunId
class SelfCorrelationCheck(Contract):
alpha_ids: list[AlphaId] = Field(min_length=1, max_length=100)
class SelfCorrelationReference(Contract):
alpha_id: AlphaId
class History(Page): class History(Page):
source: str | None = Field(default=None, max_length=100) source: str | None = Field(default=None, max_length=100)
reference: str | None = Field(default=None, max_length=200) reference: str | None = Field(default=None, max_length=200)
+32 -2
View File
@@ -16,6 +16,7 @@ from ..catalog.contracts import CatalogJobInput
from ..catalog.platform import platform_options, validate_platform_scope from ..catalog.platform import platform_options, validate_platform_scope
from ..catalog.research_metadata import ResearchMetadata, availability_key from ..catalog.research_metadata import ResearchMetadata, availability_key
from ..catalog.service import Catalog from ..catalog.service import Catalog
from ..correlation import MIN_SAMPLES, THRESHOLD, WINDOW_YEARS
from ..models import Account, Alpha, BacktestItem, Job, JobItem, ResearchRequest, SimulationAttempt, now from ..models import Account, Alpha, BacktestItem, Job, JobItem, ResearchRequest, SimulationAttempt, now
from ..research.serialization import encode_snapshot from ..research.serialization import encode_snapshot
from ..research.workspace_contracts import FieldAvailabilityInput from ..research.workspace_contracts import FieldAvailabilityInput
@@ -48,7 +49,15 @@ class ResearchAccess:
"settings_schema": DirectCandidate.model_json_schema(), "settings_schema": DirectCandidate.model_json_schema(),
"confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID", "confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes), "duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
"metadata_only": True, "actual_platform_allowance": None} "metadata_only": True, "actual_platform_allowance": None,
"self_correlation": {
"check_with": "check_self_correlation", "read_with": "get_self_correlation",
"job_with": "get_refresh_job", "max_targets": 100, "source": "local",
"reference_scope": "本地已同步的同地区已提交 Alpha,排除自身",
"method": "累计 PnL 日变化的 Pearson 相关系数,取带符号最大值",
"threshold": THRESHOLD, "min_samples": MIN_SAMPLES, "window_years": WINDOW_YEARS,
"platform_check": False,
}}
async def catalog(self, args): async def catalog(self, args):
data = await Catalog(self.db).search(args.filters, args.dataset_id) data = await Catalog(self.db).search(args.filters, args.dataset_id)
@@ -101,7 +110,7 @@ class ResearchAccess:
async def refresh_job(self, args): async def refresh_job(self, args):
job = await self.db.get(Job, args.job_id) job = await self.db.get(Job, args.job_id)
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh"}: if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "self_correlation"}:
raise ResearchError("NOT_FOUND", "研究刷新任务不存在") raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
result = await self.business.get_job_status(args.job_id) result = await self.business.get_job_status(args.job_id)
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None)) query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
@@ -111,6 +120,27 @@ class ResearchAccess:
return {**result, "job_id": job.id, "artifact_reference": job.payload, return {**result, "job_id": job.id, "artifact_reference": job.payload,
"errors": page([{"alpha_id": e.alpha_id, "error": e.error} for e in errors], total, args.limit, args.offset)} "errors": page([{"alpha_id": e.alpha_id, "error": e.error} for e in errors], total, args.limit, args.offset)}
async def check_self_correlation(self, args):
"""Queue local checks for synced IDs; caller commits before waking the runner.
The shared job service deduplicates active batches. Missing PnL is fetched
by the durable runner, so slow upstream reads do not hold the MCP call.
"""
ids = sorted(set(args.alpha_ids))
existing = set(await self.db.scalars(select(Alpha.id).where(Alpha.id.in_(ids))))
if existing != set(ids):
raise ResearchError("NOT_FOUND", "部分 Alpha 尚未同步,请先导入", affected_items=sorted(set(ids)-existing))
job = await self.business.create_sync_job(JobInput(kind="self_correlation", alpha_ids=ids))
self.wake = "jobs"
return {"job_id": job["id"], "status": job["status"], "alpha_ids": ids,
"source": "local", "job_with": "get_refresh_job", "read_with": "get_self_correlation"}
async def self_correlation(self, args):
"""Read the latest local result without fetching PnL or starting a check."""
data = await self.business.get_self_correlation(args.alpha_id)
status = "not_cached" if not data["cached"] else "stale" if data["result"]["stale"] else "available"
return {"alpha_id": args.alpha_id, "source": "local", "status": status, **data}
async def history(self, args): async def history(self, args):
return await self.evidence.history(args) return await self.evidence.history(args)
+106 -1
View File
@@ -162,7 +162,7 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
async with ClientSession(streams[0], streams[1]) as client: async with ClientSession(streams[0], streams[1]) as client:
await client.initialize() await client.initialize()
listed = await client.list_tools() listed = await client.list_tools()
assert len(listed.tools) == 11 assert len(listed.tools) == 13
caps = await client.call_tool("get_research_capabilities", {}) caps = await client.call_tool("get_research_capabilities", {})
assert caps.structured_content["max_candidates"] == 100 assert caps.structured_content["max_candidates"] == 100
result = await client.call_tool("submit_backtests", submission()) result = await client.call_tool("submit_backtests", submission())
@@ -278,3 +278,108 @@ async def test_refresh_job_error_pages_and_kind_isolation(mcp_app):
assert result["errors"]["items"][0]["alpha_id"] == "1" assert result["errors"]["items"][0]["alpha_id"] == "1"
error = await mcp_app.state.mcp.invoke(principal, "get_refresh_job", {"job_id": "auth"}) error = await mcp_app.state.mcp.invoke(principal, "get_refresh_job", {"job_id": "auth"})
assert error.structured_content["error"]["code"] == "NOT_FOUND" assert error.structured_content["error"]["code"] == "NOT_FOUND"
async def test_self_correlation_sdk_workflow_cache_and_staleness(mcp_app, monkeypatch):
import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from app.alphas import upsert_alpha
from app.models import Job, SelfCorrelation
from tests.conftest import alpha
from tests.test_alpha_management import points
data = points([1, -2, 4, 3] * 20)
async with mcp_app.state.sessions.begin() as db:
for raw in (alpha("target"), alpha("peer", status="ACTIVE"), alpha("pending")):
await upsert_alpha(db, raw)
db.add(Pnl(alpha_id="target", raw={}, points=data))
calls = []
async def pnl(alpha_id):
calls.append(alpha_id)
return {"records": [{"date": p["date"], "pnl": p["value"]} for p in data]}
monkeypatch.setattr(mcp_app.state.runner.client, "pnl", pnl)
principal, secret = await credentials(mcp_app, {"research:read", "research:refresh"})
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=mcp_app),
headers={"Authorization": f"Bearer {secret}"}) as http:
async with streamable_http_client("http://testserver/api/v1/mcp/", http_client=http) as streams:
async with ClientSession(streams[0], streams[1]) as client:
await client.initialize()
listed = {t.name: t for t in (await client.list_tools()).tools}
assert listed["get_self_correlation"].annotations.read_only_hint
assert not listed["check_self_correlation"].annotations.read_only_hint
assert listed["check_self_correlation"].annotations.open_world_hint
before = await client.call_tool("get_self_correlation", {"alpha_id": "target"})
assert before.structured_content["status"] == "not_cached"
assert before.structured_content["result"] is None and calls == []
mcp_app.state.runner.wake.clear()
started = await client.call_tool("check_self_correlation", {"alpha_ids": ["target", "target"]})
assert not started.is_error, started
job_id = started.structured_content["job_id"]
assert mcp_app.state.runner.wake.is_set() and calls == []
assert started.structured_content["alpha_ids"] == ["target"]
again = await client.call_tool("check_self_correlation", {"alpha_ids": ["target"]})
assert again.structured_content["job_id"] == job_id
queued = await client.call_tool("get_refresh_job", {"job_id": job_id})
assert queued.structured_content["status"] == "queued"
await mcp_app.state.runner.execute(job_id)
done = await client.call_tool("get_refresh_job", {"job_id": job_id})
assert done.structured_content["status"] == "completed"
assert done.structured_content["processed"] == 1
result = await client.call_tool("get_self_correlation", {"alpha_id": "target"})
body = result.structured_content
assert body["source"] == "local" and body["status"] == "available"
assert body["result"]["max_correlation"] == pytest.approx(1)
assert body["result"]["compared_count"] == 1
assert body["result"]["matches"][0]["alpha_id"] == "peer"
assert calls == ["peer"]
async with mcp_app.state.sessions.begin() as db:
row = await db.get(SelfCorrelation, "target")
row.stale = True
audit = await db.scalar(select(MCPAudit).where(MCPAudit.tool == "check_self_correlation"))
assert audit.business_id == job_id and audit.result_code == "OK"
assert await db.scalar(select(func.count()).select_from(Job).where(Job.kind == "self_correlation")) == 1
assert await db.get(Pnl, "peer") is not None
stale = await invoke(mcp_app, principal, "get_self_correlation", {"alpha_id": "target"})
assert stale["status"] == "stale" and stale["result"]["stale"] and calls == ["peer"]
async def test_self_correlation_read_only_scope_and_invalid_inputs(mcp_app):
from app.alphas import upsert_alpha
from app.models import Job
from tests.conftest import alpha
async with mcp_app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("known"))
_, secret = await credentials(mcp_app, {"research:read"})
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=mcp_app), base_url="http://testserver",
headers={"Authorization": f"Bearer {secret}", "Accept": "application/json, text/event-stream"}) as http:
listed = await http.post(ENDPOINT, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = {t["name"] for t in listed.json()["result"]["tools"]}
assert "get_self_correlation" in names and "check_self_correlation" not in names
for name, args, status in (
("check_self_correlation", {"alpha_ids": ["known"]}, 403),
("get_self_correlation", {"alpha_id": "known"}, 200),
):
response = await http.post(ENDPOINT, json={"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": name, "arguments": args}})
assert response.status_code == status
principal, _ = await credentials(mcp_app)
for args, code in (
({"alpha_ids": []}, "INVALID_INPUT"),
({"alpha_ids": ["known"] * 101}, "INVALID_INPUT"),
({"alpha_ids": ["../secret"]}, "INVALID_INPUT"),
({"alpha_ids": ["known"], "force": True}, "INVALID_INPUT"),
({"alpha_ids": ["known", "missing"]}, "NOT_FOUND"),
):
failure = await mcp_app.state.mcp.invoke(principal, "check_self_correlation", args)
assert failure.is_error and failure.structured_content["error"]["code"] == code
if code == "NOT_FOUND":
assert failure.structured_content["error"]["affected_items"] == ["missing"]
missing = await mcp_app.state.mcp.invoke(principal, "get_self_correlation", {"alpha_id": "missing"})
assert missing.is_error and missing.structured_content["error"]["code"] == "NOT_FOUND"
async with mcp_app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(Job)) == 0
+18 -4
View File
@@ -1,6 +1,6 @@
# MCP 研究接入 # MCP 研究接入
MCP 让外部助手直接查询数据元信息、查回测历史、提交固定候选并读取结果。无需先建立特征、模板、变体或 QuantFlow。执行仍由网页共用的持久队列负责,回测页保存同一个运行。 MCP 让外部助手直接查询数据元信息、查回测历史、提交固定候选、执行本地自相关检查并读取结果。无需先建立特征、模板、变体或 QuantFlow。执行仍由网页共用的持久队列负责,回测页保存同一个运行。
## 启用与令牌 ## 启用与令牌
@@ -33,8 +33,8 @@ python -m app.cli mcp-token-revoke TOKEN_ID
| 权限 | 可调用能力 | | 权限 | 可调用能力 |
| --- | --- | | --- | --- |
| research:read | 能力、数据目录、元数据、历史、运行、结果、证据、刷新任务查询 | | research:read | 能力、数据目录、元数据、历史、运行、结果、证据、刷新及自相关任务查询、自相关结果读取 |
| research:refresh | 显式更新元数据及 PnL 缓存;同时要求 read | | research:refresh | 显式更新元数据及 PnL 缓存、发起本地自相关检查;同时要求 read |
| backtests:execute | 直接提交固定候选;同时要求 read | | backtests:execute | 直接提交固定候选;同时要求 read |
| backtests:control | 暂停、继续、停止、恢复采集;同时要求 read | | backtests:control | 暂停、继续、停止、恢复采集;同时要求 read |
@@ -50,7 +50,9 @@ python -m app.cli mcp-token-revoke TOKEN_ID
| search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 | | search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 |
| get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability | | get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability |
| refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl | | refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl |
| get_refresh_job | `{job_id,limit?,offset?}`;错误列表独立分页 | | get_refresh_job | `{job_id,limit?,offset?}`;查询刷新或自相关任务,错误列表独立分页 |
| check_self_correlation | `{alpha_ids:[...]}`,1–100 个已导入 Alpha ID;异步返回 job_id |
| get_self_correlation | `{alpha_id}`;只读最新本地结果,含缓存和 stale 状态 |
| search_backtests | 来源、reference、status、带时区起止时间、scope、q、候选精确匹配及分页 | | search_backtests | 来源、reference、status、带时区起止时间、scope、q、候选精确匹配及分页 |
| submit_backtests | `{name,candidates,idempotency_key,duplicate_policy?,source?}` | | submit_backtests | `{name,candidates,idempotency_key,duplicate_policy?,source?}` |
| get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 | | get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 |
@@ -62,6 +64,18 @@ metadata 的 operators 支持 q/category 和分页;settings 支持分页;fie
列表默认 25 项、最多 100 项;返回 total、offset、limit、has_more。快照证据按顶层 key/value 分页,嵌套内容完整保留;PnL 按日期过滤和分页。元数据仅提供字段与算子资料,不提供原始财务时间序列。 列表默认 25 项、最多 100 项;返回 total、offset、limit、has_more。快照证据按顶层 key/value 分页,嵌套内容完整保留;PnL 按日期过滤和分页。元数据仅提供字段与算子资料,不提供原始财务时间序列。
## 本地自相关检查
目标 Alpha 必须已导入;比较基准为本地已同步的同地区已提交 Alpha,排除自身。建议先在网页全量同步已提交 Alpha。MCP 不隐式导入 Alpha 或同步基准列表。
1. 调用 `check_self_correlation`,例如 `{"alpha_ids":["LL977PqL"]}`,获取 `job_id`。目标去重排序,相同目标集合的活动任务会复用;完成后再次调用会重新计算。
2. 用 `get_refresh_job` 查询进度、失败原因与分页错误。受理后客户端断开不影响后台任务;取消及重试可在网页任务面板执行。
3. 完成后调用 `get_self_correlation`,例如 `{"alpha_id":"LL977PqL"}`。返回 `source=local`,`status` 为 `not_cached`、`available` 或 `stale`;没有结果时 `cached=false`、`result=null`,读取不会隐式启动检查。
检查优先使用已有 PnL 缓存,缺失时由后台自动补取并落库,等待遵循平台 Retry-After。计算使用累计 PnL 的日变化、目标最新数据日前四年的共同窗口、至少 30 个有效样本,取带符号最大的 Pearson 相关系数,阈值为 0.7。结果包含比较数、跳过数、最相关 Alpha、窗口、样本和缓存时间;最多列出前 10 个匹配及前 100 个跳过原因。缺少基准或有效样本会明确报告数据不足,不能当作通过。
`result.stale=true` 表示缓存已待重算;新任务执行期间读取仍可能返回上一次结果,应同时查看任务状态和 `calculated_at`。这是本地研究规则,不调用 WorldQuant 的提交检查,也不代表平台提交资格。`get_research_capabilities.self_correlation` 提供工具名及当前规则。已有包含 `research:refresh` 的 Key 可直接发起检查;只读 Key 只能查看结果。
## 一轮研究示例 ## 一轮研究示例
先读取能力和设置快照,发现字段并检查历史。用户授权本批执行后,提交以下形态的固定输入;设置仅为结构示例,实际范围需依据平台选项选择: 先读取能力和设置快照,发现字段并检查历史。用户授权本批执行后,提交以下形态的固定输入;设置仅为结构示例,实际范围需依据平台选项选择: