Compare commits
16 Commits
db328c62dc
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bfc787691 | |||
| a1b160e1b0 | |||
| 79b432c3d0 | |||
| a3cb6dbacf | |||
| 5a7f39726b | |||
| 99bc36439e | |||
| 69c19ed25f | |||
| 13a2168ca5 | |||
| 07dd767c52 | |||
| 34f1a4fa77 | |||
| ba60d8e5c4 | |||
| 20f6d0fc51 | |||
| db449f0915 | |||
| 8444e3055e | |||
| e256d6fef1 | |||
| 7c8188df9c |
@@ -1,13 +1,15 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [prod]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Reuse the runner that deploys zhixing-system to the same Docker host.
|
||||
runs-on: ubuntu-latest
|
||||
# Manual runs must also select prod before receiving production credentials.
|
||||
if: ${{ github.ref == 'refs/heads/prod' }}
|
||||
# Match the label of tencent-prod-runner on server B, not its runner name.
|
||||
runs-on: tencent-prod
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
@@ -22,7 +24,6 @@ jobs:
|
||||
WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
|
||||
DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }}
|
||||
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
|
||||
MCP_ENABLED: ${{ vars.MCP_ENABLED }}
|
||||
run: bash scripts/deploy-production.sh
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Alpha 管理候选PPAC
|
||||
Type: task
|
||||
Status: ready-for-agent
|
||||
|
||||
用户要求新增固定候选清单,并将对应检查结果显示为黄色“候选PPAC”Tag。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 仅有一项 Alpha FAIL 且名称为 PURE_POWER_POOL_THEME 时,检查结果派生为 PPAC_CANDIDATE;REGULAR_SUBMISSION 沿用既有排除口径,WARNING/PENDING 不计失败。
|
||||
- 固定“候选PPAC”Tab 通过 ppac_candidate=true 在数据库分页前筛选未提交候选;叠加筛选、分页、导出、保存/恢复视图及 AI 页面上下文保持一致。
|
||||
- 列表和详情显示黄色候选状态,保留原始 FAIL 检查证据与本地研究记录。候选身份不表示当前可正式提交。
|
||||
- 迁移 0022 复用已有 check_type 索引,仅重分类匹配的历史缓存。同步和主动检查沿用 snapshot_columns 自动刷新分类。
|
||||
|
||||
## 验证
|
||||
|
||||
分类边界、组合筛选/分页/导出、同步与主动检查后的状态迁移、隔离历史迁移、后端静态检查、前端构建和浏览器固定 Tab/黄色 Tag/保存恢复/窄屏验证。
|
||||
|
||||
## Comments
|
||||
|
||||
已完成本地实现与验证:95 项后端回归通过,包含分类边界、真实业务执行器对模拟 /check 的状态刷新、筛选/分页/导出/保存视图、503 条历史缓存的升级/降级/再升级及迁移 schema check;Ruff、TypeScript 与生产构建通过。
|
||||
|
||||
原有 Alpha 管理、提交受阻和本地相关性筛选 3 项浏览器回归通过。另在隔离临时库使用 56 条合成 PPAC Alpha 验证:固定 Tab、列表/详情黄色 Tag、原始 FAIL 保留、跨页勾选、搜索后回第一页及清空选择、保存/恢复视图、空结果和重置保持候选范围。1920px 桌面与 720px 窄屏完成截图检查,行高为 40px,窄屏分页底部 887px 位于 900px 视口内,页面未横向溢出,表体可独立横向滚动。
|
||||
|
||||
截图位于忽略目录 output/playwright/ppac-candidate-{desktop,detail,narrow}.png。构建保留已有 lottie eval 和大包提示;浏览器仅有登录前 auth/me 的 401 与 favicon 404。未部署、未迁移实际业务数据库、未请求真实平台。
|
||||
@@ -0,0 +1,15 @@
|
||||
# Pyramid distribution MCP
|
||||
Status: ready-for-agent
|
||||
|
||||
按 region/delay 实时读取本季度个人 Pyramid 分布,按 >=3、1–2、0 分组。
|
||||
复用平台认证和 MCP 只读权限;缺失或非法计数不能当作零。
|
||||
验证 MCP 调用、边界值、输入/上游错误,并展示实时样例。不发布、不提交。
|
||||
|
||||
## Comments
|
||||
|
||||
- 已实现 get_pyramid_distribution(region, delay),复用现有 WqClient 和 research:read。
|
||||
- 实测传日期后返回全零,日期参数语义尚未确认;最终使用已验证的无日期请求,period=platform_default,不宣称独立验证季度边界。
|
||||
- MCP 及新功能 20 项测试通过,Ruff / diff check 通过。使用内存数据库隔离认证令牌与审计,通过 MCP invoke 调用真实平台:USA/D1 已点亮0类、进行中5类、零计数11类。
|
||||
|
||||
- 按用户新要求改为必传 current_date,自动计算完整自然季度,发送 startDate/endDate;不再使用默认周期。2026-09-13 对应 2026-07-01 至 2026-09-30。
|
||||
- 33 项测试通过(含四季度边界、跨年、闰日、日期校验),Ruff 通过。真实 MCP invoke:USA/D1 全零;GLB/D1 fundamental/risk 各3;AMR/D1 risk 为1,其余零。此前默认周期样例已被此次明确季度结果替代。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Super Alpha 研究、管理及通用回测接入
|
||||
|
||||
Status: ready-for-agent
|
||||
Outcome: implemented
|
||||
|
||||
## 工作项
|
||||
|
||||
- [x] 公共契约、组件快照和增量迁移
|
||||
- [x] 通用回测及结果证据支持 SUPER
|
||||
- [x] 方案、Selection 任务和 MCP
|
||||
- [x] 研究及管理页面、路由和范围隔离
|
||||
- [x] 模拟平台测试、构建、浏览器及迁移验证
|
||||
|
||||
## Comments
|
||||
|
||||
2026-09-13:开始实施用户已确认计划。只执行本地模拟验证,保护已有 Pyramid MCP 改动。
|
||||
|
||||
2026-09-13:完成两个菜单、共享 SUPER 回测契约、方案版本与构造记录、独立组件证据、8 个 MCP 工具及通用工具扩展。使用文档已更新至 docs/mcp-research.md。
|
||||
|
||||
验证记录:后端全量 540 项通过;后续元数据、来源和幂等调整后的针对性回归分别 44 项、30 项通过。前端类型检查与构建通过;Super Alpha、原回测、管理、MCP Key、研究导航、侧栏和原工作空间浏览器场景均通过。新列表验证了 40px 行高,检查 1440/850/390px 视口。迁移 0021 在专用临时 PostgreSQL 中完成回退/重升级、历史 REGULAR 回测及已有 SUPER/备注保留、SUPER 完整闭环与并发幂等验证。
|
||||
|
||||
范围:只使用模拟平台和可丢弃的本地测试数据库;未迁移用户运行库,未执行真实 SUPER 模拟、正式 Alpha 提交、部署或 git 提交。升级运行环境时需应用 0021;真实 SUPER 模拟须按用户计划另行授权验收。构建保留既有的大包及 lottie-web eval 提示。
|
||||
@@ -0,0 +1,14 @@
|
||||
# Super Alpha 研究与管理
|
||||
|
||||
按用户 2026-09-13 确认的功能规划实施。
|
||||
|
||||
- 新增 Super Alpha 研究(方案、版本、Selection 预览、参数展开、固定候选及对照)和 Super Alpha 管理菜单。
|
||||
- 管理菜单以 SUPER / 非 SUPER 分流,共用 Alpha 记录、同步、备注和检查。网页 AI 只识别新页面和对象。
|
||||
- 复用通用回测、研究资产和不可变版本。SUPER 候选包含 selection/combo/完整设置,每个 SUPER 单独发平台请求,共用配额与恢复。
|
||||
- 组件预览与实际组件证据独立,保存请求指纹、组件指纹、时间和完整性。未知不等于通过。
|
||||
- 增加方案查询/读取/保存、Selection 预览/读取、候选构造及 SUPER 成果查询/读取 MCP 工具,回测仍使用通用工具。
|
||||
- 不实现自动研究循环、独立调度器或正式 Alpha 提交;不调用真实模拟或收费模型。
|
||||
|
||||
验收:网页及 MCP 完整闭环、范围隔离、版本及幂等、组件异常、SUPER 回测恢复与历史 REGULAR 兼容;后端测试、前端构建、浏览器和独立迁移验证。
|
||||
|
||||
已有 Pyramid MCP 未提交改动必须保留。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 简化模板到批量回测流程
|
||||
Status: ready-for-agent
|
||||
|
||||
## 范围
|
||||
模板详情保持编辑、保存、新增版本;回测准备选择数据准备并展开候选,候选集合即用户确认界面,点击回测直接启动批量任务并导航到回测研究。
|
||||
模板生成仅保留表达式语法与数据准备/回测参数组合一致性检查;不持久化逐候选校验状态,不依赖算子/字段可用性缓存。字段类型用于候选域选择,不作为表达式类型检查。
|
||||
模板侧不提供评估研究结果、变体关系;保留回测来源关联。其他研究生产者的行为不扩大修改。
|
||||
|
||||
## 验证
|
||||
覆盖语法/组合失败不保存候选、无元数据仍可生成、旧记录不被旧校验状态阻塞、启动幂等与来源、候选分页/选择/直接启动导航。
|
||||
|
||||
## 完成结果
|
||||
已实现模板独立候选确认界面及单次回测接口,内部在同一事务创建执行快照与批量任务;按账户锁和请求键保证重试幂等,提交后唤醒执行器。
|
||||
新模板候选不保存 validation;历史行状态不再参与模板资格判定。旧快照按语法和组合契约读取,其他生产者保留各自检查。
|
||||
模板参数选项同步仅辅助选择,不阻塞生成。修改准备参数会移除旧候选;迟到的生成响应不覆盖新的准备状态。
|
||||
|
||||
## 验证结果
|
||||
- Ruff、前端 typecheck、git diff --check 通过。
|
||||
- 后端研究工作区、MCP模板、研究流水线、批量回测:97项通过。
|
||||
- 现有设置、研究导航、研究结果浏览器回归:6项通过。
|
||||
- Playwright CLI 在隔离模拟环境验证51条候选、跨页选择、40px行高、窄屏滚动、参数变化使候选失效、取消1条后单击回测创建50条任务并导航;无额外预览/评估/变体关系入口,保存来源关联。
|
||||
- 未连接真实平台执行回测,未执行生产数据库变更,未提交或推送代码。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 对齐 MCP 与内置 bot 的模板流程
|
||||
Status: ready-for-agent
|
||||
|
||||
## 范围
|
||||
模板来源回测证据改为可选,提供时继续校验真实完整性。MCP 补齐模板版本+数据准备生成候选、分页读取固定候选、按候选 ID 批量回测,沿用权限、幂等、原子事务及审计。
|
||||
内置 bot 可直接对模板候选集合请求一次确认,不再单独准备预览;统一模板语法/组合规则与来源。通用回测、SUPER、变体与结果评估工具保持既有职责。
|
||||
|
||||
## 验证
|
||||
覆盖无来源创建与新增版本、来源错误、分页不截断执行、生成不启动、执行权限隔离、重试幂等及内容冲突、确认前零回测、拒绝/变更确认、提交后唤醒和关联追溯。
|
||||
|
||||
## 完成记录
|
||||
已实现可选来源证据、MCP 候选生成/分页读取/按集合执行,以及内置 bot 的一次确认回测。模板共用语法与组合一致性规则;完整候选摘要在确认时固定,执行前核对,保持模板来源与调用方审计关联。
|
||||
|
||||
验证:相关后端测试共 132 项通过(MCP 模板与研究集成 33 项;MCP/模板工作区/AI/能力回归 99 项,其中 SDK 工具数量断言更新后单独复跑通过)。Ruff 和 git diff --check 通过。使用隔离数据库、模拟模型和平台;未执行真实平台回测。未提交或推送代码。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 模板详情与回测准备迭代
|
||||
Type: task
|
||||
Status: ready-for-human
|
||||
|
||||
## 范围
|
||||
模板详情移除 AI、假设、导入、删除、历史选择等交互;仅保留名称、类别、研究解释、表达式及占位符类型/描述,操作为保存和新增版本。回测准备按表达式、数据准备、展开选项、回测参数、生成候选集合排列。允许无取值模板保存,展开时按字段类型绑定固定输入;非字段参数不推测。保留现有不可变版本与并发保护,提供 bot 创建及新增版本能力。
|
||||
|
||||
## 验证
|
||||
类型检查、后端模板保存/展开/版本/bot 回归、浏览器实际交互。
|
||||
|
||||
## Answer
|
||||
已完成前后端实现。保存修改沿用不可变版本;新增版本允许内容不变时显式创建下一版。模板工坊仅配置字段类型与描述,已有显式 values 仍作为候选限制保留;空 field 从所选固定输入按类型绑定,非字段空参数返回明确 422。新增内置 bot 模板创建/版本能力和外部 MCP 版本、查询能力。
|
||||
|
||||
验证:前端 typecheck、改动文件 Ruff、git diff --check 通过。模板与工作空间 50 项测试通过;AI/MCP 回归 39 项通过,1 项因新增工具导致总数断言变化,更新断言后单独复跑通过。两条既有浏览器回归通过。Playwright CLI 实测字段去重/删除、描述保存、类型切换、保存 v1/新增 v2/编辑保存 v3、历史不变、选择数据准备后生成候选且未启动回测。截图位于 output/playwright/template-detail-editor.png 与 template-detail-prepare.png。验证使用隔离数据库和模拟平台。
|
||||
@@ -45,6 +45,8 @@ class PageContext(Contract):
|
||||
page: Literal[
|
||||
"home",
|
||||
"alphas",
|
||||
"superalphas",
|
||||
"superalpha-research",
|
||||
"account",
|
||||
"datasets",
|
||||
"fields",
|
||||
|
||||
+22
-4
@@ -31,16 +31,20 @@ def snapshot_columns(settings, metrics, checks, *, checked=False):
|
||||
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check
|
||||
with no failures is PASS. WARNING/PENDING do not count as failures, matching
|
||||
the legacy workflow. Empty, malformed or unknown results remain PENDING.
|
||||
No submission eligibility or activity eligibility is inferred here.
|
||||
A sole PURE_POWER_POOL_THEME failure is a PPAC candidate, not confirmation
|
||||
of current submission or activity eligibility. Raw failures stay available.
|
||||
"""
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
metrics = metrics if isinstance(metrics, dict) else {}
|
||||
blocked = submission_limits(checks)["status"] == "blocked"
|
||||
checks, _ = split_checks(checks)
|
||||
valid = [check for check in checks if isinstance(check, dict)]
|
||||
failures = len(failed_checks(checks))
|
||||
failed_names = failed_checks(checks)
|
||||
failures = len(failed_names)
|
||||
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
|
||||
if failures:
|
||||
if failed_names == ["PURE_POWER_POOL_THEME"]:
|
||||
check_type = "PPAC_CANDIDATE"
|
||||
elif failures:
|
||||
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
|
||||
elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid):
|
||||
check_type = "PENDING"
|
||||
@@ -75,7 +79,7 @@ def check_summary(checks, *, check_type):
|
||||
"check_type": check_type,
|
||||
"failed_checks": failed_checks(checks),
|
||||
"submission_limits": submission_limits(checks),
|
||||
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
|
||||
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项;PPAC_CANDIDATE 为唯一失败项是 PURE_POWER_POOL_THEME 的候选。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
|
||||
}
|
||||
|
||||
|
||||
@@ -189,12 +193,21 @@ async def upsert_alpha(db, raw: dict):
|
||||
|
||||
def list_statement(filters):
|
||||
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
|
||||
if filters.management_scope == "super":
|
||||
query = query.where(Alpha.alpha_type == "SUPER")
|
||||
elif filters.management_scope == "non_super":
|
||||
query = query.where(or_(Alpha.alpha_type != "SUPER", Alpha.alpha_type.is_(None)))
|
||||
if filters.submission:
|
||||
query = query.where(submission_condition(filters.submission))
|
||||
if filters.submission_blocked is not None:
|
||||
query = query.where(Alpha.submission_blocked == filters.submission_blocked)
|
||||
if filters.submission_blocked:
|
||||
query = query.where(submission_condition("UNSUBMITTED"))
|
||||
if filters.ppac_candidate is not None:
|
||||
candidate = Alpha.check_type == "PPAC_CANDIDATE"
|
||||
query = query.where(candidate if filters.ppac_candidate else ~candidate)
|
||||
if filters.ppac_candidate:
|
||||
query = query.where(submission_condition("UNSUBMITTED"))
|
||||
if (filters.local_correlation_status is not None or filters.local_correlation_min is not None
|
||||
or filters.local_correlation_max is not None):
|
||||
# One cache row per Alpha keeps totals/export stable; stale overrides the displayed status.
|
||||
@@ -285,6 +298,11 @@ def summary(item: Alpha, research: Research):
|
||||
result = {k: getattr(item, k) for k in keys}
|
||||
result["failed_checks"] = failed_checks(item.checks)
|
||||
result["expression_preview"] = (item.expression or item.selection or "")[:240]
|
||||
result["selection_preview"], result["combo_preview"] = (item.selection or "")[:240], (item.combo or "")[:240]
|
||||
if item.alpha_type == "SUPER":
|
||||
from .superalpha.evidence import parse_components
|
||||
components = parse_components(item.raw.get("components", item.raw.get("selectedAlphas")))
|
||||
result["component_count"] = len(components["components"]) if components["complete"] else None
|
||||
result["research"] = {
|
||||
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ async def wake_backtests(runner, result):
|
||||
runner.backtests.wake.set()
|
||||
|
||||
|
||||
INSTRUCTIONS = "回测先读取能力再准备固定候选预览,每次运行确认一次;后续候选新建预览。停止生成不取消回测。\n回测结果追问用 get_backtest/get_backtest_results。上下文或历史没有运行 ID 时,可用 list_backtests 按 source=chatbox 和会话 reference 找回;不得把启动返回当作结果。"
|
||||
INSTRUCTIONS = "模板集合使用 start_template_backtest 直接请求确认;其他回测先读取能力再准备固定候选预览。每次运行确认一次;后续候选新建预览。停止生成不取消回测。\n回测结果追问用 get_backtest/get_backtest_results。上下文或历史没有运行 ID 时,可用 list_backtests 按 source=chatbox 和会话 reference 找回;不得把启动返回当作结果。"
|
||||
|
||||
|
||||
CAPABILITIES = (
|
||||
|
||||
@@ -27,25 +27,47 @@ class SimulationSettings(Contract):
|
||||
maxPosition: Literal["ON", "OFF"] = "OFF"
|
||||
|
||||
|
||||
class SuperSimulationSettings(SimulationSettings):
|
||||
"""SUPER-only selection settings; platform metadata still determines availability."""
|
||||
|
||||
selectionHandling: Literal["POSITIVE", "NON_ZERO", "NON_NAN"]
|
||||
selectionLimit: int = Field(ge=1, le=100000, strict=True)
|
||||
componentActivation: Literal["IS", "OS"]
|
||||
|
||||
|
||||
class Candidate(Contract):
|
||||
client_item_id: str = Field(min_length=1, max_length=100)
|
||||
expression: str = Field(min_length=1, max_length=20000)
|
||||
settings: SimulationSettings
|
||||
alpha_type: Literal["REGULAR"] = "REGULAR"
|
||||
expression: str = Field(default="", max_length=20000)
|
||||
selection: str | None = Field(default=None, max_length=20000)
|
||||
combo: str | None = Field(default=None, max_length=20000)
|
||||
settings: SuperSimulationSettings | SimulationSettings
|
||||
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||
|
||||
@field_validator("expression")
|
||||
@field_validator("expression", "selection", "combo")
|
||||
@classmethod
|
||||
def nonempty(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("表达式不能为空")
|
||||
return value
|
||||
return value.strip() if value is not None else None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def typed_input(self):
|
||||
if self.alpha_type == "SUPER":
|
||||
if self.expression or not self.selection or not self.combo:
|
||||
raise ValueError("SUPER 必须提供非空 selection/combo,不能提供 regular expression")
|
||||
if not isinstance(self.settings, SuperSimulationSettings):
|
||||
raise ValueError("SUPER 必须提供 selectionHandling、selectionLimit、componentActivation")
|
||||
elif not self.expression or self.selection is not None or self.combo is not None or isinstance(self.settings, SuperSimulationSettings):
|
||||
raise ValueError("REGULAR 必须提供非空 expression,不能包含 SUPER 表达式或设置")
|
||||
return self
|
||||
|
||||
def platform_input(self):
|
||||
if self.alpha_type == "SUPER":
|
||||
return {"type": "SUPER", "selection": self.selection, "combo": self.combo,
|
||||
"settings": self.settings.model_dump()}
|
||||
return {"type": self.alpha_type, "regular": self.expression, "settings": self.settings.model_dump()}
|
||||
|
||||
|
||||
class Source(Contract):
|
||||
research_kind: str | None = Field(default=None, max_length=50)
|
||||
kind: str = Field(default="manual", min_length=1, max_length=100)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
batch_id: str | None = Field(default=None, max_length=200)
|
||||
@@ -54,6 +76,9 @@ class Source(Contract):
|
||||
research_id: str | None = Field(default=None, max_length=200)
|
||||
parent_run_id: str | None = Field(default=None, max_length=36)
|
||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
||||
superalpha_plan_id: str | None = Field(default=None, max_length=36)
|
||||
superalpha_plan_version: int | None = Field(default=None, ge=1)
|
||||
selection_snapshot_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class SourceOutput(Source):
|
||||
@@ -129,7 +154,7 @@ def fingerprint(payload: dict) -> str:
|
||||
|
||||
def group_key(candidate: dict):
|
||||
settings = candidate["settings"]
|
||||
return tuple(settings[k] for k in ("region", "delay", "language", "instrumentType"))
|
||||
return (candidate.get("alpha_type", "REGULAR"), *tuple(settings[k] for k in ("region", "delay", "language", "instrumentType")))
|
||||
|
||||
|
||||
class ReferenceInput(Contract):
|
||||
@@ -203,7 +228,10 @@ class ItemOutput(Contract):
|
||||
id: str
|
||||
client_item_id: str
|
||||
expression: str
|
||||
settings: SimulationSettings
|
||||
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||
selection: str | None = None
|
||||
combo: str | None = None
|
||||
settings: SuperSimulationSettings | SimulationSettings
|
||||
attempt_id: str
|
||||
platform_status: str
|
||||
collection_status: str
|
||||
|
||||
@@ -117,13 +117,14 @@ async def runs(
|
||||
source: str | None = Query(None, max_length=100),
|
||||
reference: str | None = Query(None, max_length=200),
|
||||
research_id: str | None = Query(None, max_length=200),
|
||||
alpha_type: Literal["REGULAR", "SUPER"] | None = None,
|
||||
q: str = Query("", max_length=200),
|
||||
sort: Literal["name", "created_at"] = "created_at",
|
||||
direction: Literal["asc", "desc"] = "desc",
|
||||
):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await Business(db).backtests.runs(
|
||||
limit, offset, source, reference, research_id, q, sort, direction
|
||||
limit, offset, source, reference, research_id, q, sort, direction, alpha_type
|
||||
)
|
||||
|
||||
|
||||
@@ -195,3 +196,17 @@ async def attach_reference(attempt_id: str, body: ReferenceInput, request: Reque
|
||||
async def subset(preview_id: str, body: SubsetInput, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await Business(db).backtests.subset(preview_id, body)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/artifact")
|
||||
async def artifact(item_id: str, request: Request, kind: Literal["snapshot", "components", "pnl"], limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..research_access.contracts import Artifact
|
||||
from ..research_access.queries import EvidenceQueries
|
||||
from ..research_access.service import ResearchError
|
||||
async with request.app.state.sessions() as db:
|
||||
try:
|
||||
return await EvidenceQueries(db).artifact(Artifact(item_id=item_id, kind=kind, limit=limit, offset=offset))
|
||||
except ResearchError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
|
||||
@@ -448,7 +448,7 @@ class BacktestLane:
|
||||
|
||||
def safe_progress(self, value):
|
||||
# Store useful protocol evidence, never arbitrary upstream diagnostics or credentials.
|
||||
result = {k: value[k] for k in ("status", "alpha", "regular", "settings", "location") if k in value}
|
||||
result = {k: value[k] for k in ("status", "type", "alpha", "regular", "selection", "combo", "settings", "location", "warnings") if k in value}
|
||||
message = value.get("error") or value.get("message")
|
||||
if isinstance(message, str):
|
||||
for secret in list(self.client.credentials or ()) + list(self.client.client.cookies.values()):
|
||||
@@ -485,11 +485,13 @@ class BacktestLane:
|
||||
matched = [
|
||||
i
|
||||
for i in items
|
||||
if i.expression == expression
|
||||
if ((i.alpha_type == "REGULAR" and evidence.get("type", "REGULAR") == "REGULAR" and i.expression == expression)
|
||||
or (i.alpha_type == "SUPER" and evidence.get("type") == "SUPER"
|
||||
and i.selection == code(evidence.get("selection")) and i.combo == code(evidence.get("combo"))))
|
||||
and isinstance(settings, dict)
|
||||
and all(k in settings and settings[k] == v for k, v in i.settings.items())
|
||||
]
|
||||
if count == 1:
|
||||
if count == 1 and items[0].alpha_type == "REGULAR":
|
||||
matched = (
|
||||
items
|
||||
if (expression == items[0].expression or (not expression and detail is None))
|
||||
@@ -499,6 +501,9 @@ class BacktestLane:
|
||||
)
|
||||
else []
|
||||
)
|
||||
if count == 1 and items[0].alpha_type == "SUPER" and detail is None:
|
||||
# A known receipt can record progress, but saving SUPER requires full type/input evidence.
|
||||
matched = items if not any(k in evidence for k in ("type", "selection", "combo", "settings")) else matched
|
||||
# Identical inputs within a multi-submit are intentionally not position-matched.
|
||||
if len(matched) != 1 or (matched[0].simulation_id not in (None, child)):
|
||||
return
|
||||
@@ -513,6 +518,9 @@ class BacktestLane:
|
||||
if not await db.get(BacktestResult, item.id):
|
||||
from datetime import datetime
|
||||
|
||||
if item.alpha_type == "SUPER":
|
||||
from ..superalpha.evidence import save_actual_components
|
||||
await save_actual_components(db, item, detail, receipt["observed_at"])
|
||||
db.add(
|
||||
BacktestResult(
|
||||
item_id=item.id,
|
||||
|
||||
@@ -93,7 +93,7 @@ class Backtests:
|
||||
|
||||
async def capabilities(self):
|
||||
return {
|
||||
"alpha_types": ["REGULAR"],
|
||||
"alpha_types": ["REGULAR", "SUPER"],
|
||||
"languages": ["FASTEXPR"],
|
||||
"instrument_types": ["EQUITY"],
|
||||
"settings_schema": Candidate.model_json_schema(),
|
||||
@@ -111,6 +111,8 @@ class Backtests:
|
||||
from ..research.expressions import analyze
|
||||
if not body.preparation_refs and not body.input_ids:
|
||||
return
|
||||
if any(c.alpha_type == "SUPER" for c in body.candidates):
|
||||
raise HTTPException(422, "SUPER 组件快照不能使用字段数据准备集合")
|
||||
await Preparations(self.db).bind(body)
|
||||
snapshots = [await Preparations(self.db).snapshot(i) for i in body.input_ids]
|
||||
for candidate in body.candidates:
|
||||
@@ -221,6 +223,8 @@ class Backtests:
|
||||
if len(candidates) != len(selection):
|
||||
raise HTTPException(422, "选择包含不属于当前草稿的候选")
|
||||
data = {"name": draft.name, "source": draft.source, "candidates": candidates}
|
||||
from ..superalpha.service import validate_source
|
||||
await validate_source(self.db, data["source"], data["candidates"])
|
||||
candidates = DraftInput.model_validate(data).model_dump(mode="json")["candidates"]
|
||||
config = await self.db.get(BacktestConfig, 1)
|
||||
groups = defaultdict(list)
|
||||
@@ -253,6 +257,9 @@ class Backtests:
|
||||
for indices in groups.values():
|
||||
local_batches = []
|
||||
for index in indices:
|
||||
if candidates[index]["alpha_type"] == "SUPER":
|
||||
batches.append([index])
|
||||
continue
|
||||
batch = next(
|
||||
(
|
||||
b
|
||||
@@ -352,6 +359,7 @@ class Backtests:
|
||||
ordinal=i,
|
||||
client_item_id=c.client_item_id,
|
||||
expression=c.expression,
|
||||
alpha_type=c.alpha_type, selection=c.selection, combo=c.combo,
|
||||
settings=c.settings.model_dump(),
|
||||
fingerprint=fingerprint(c.platform_input()),
|
||||
)
|
||||
@@ -360,8 +368,10 @@ class Backtests:
|
||||
await self.db.flush()
|
||||
return await self.run(run.id)
|
||||
|
||||
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc"):
|
||||
async def runs(self, limit=25, offset=0, source=None, reference=None, research_id=None, q="", sort="created_at", direction="desc", alpha_type=None):
|
||||
query = select(BacktestRun)
|
||||
if alpha_type:
|
||||
query = query.where(BacktestRun.id.in_(select(BacktestItem.run_id).where(BacktestItem.alpha_type == alpha_type)))
|
||||
if q:
|
||||
query = query.where(BacktestRun.name.contains(q, autoescape=True))
|
||||
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
|
||||
@@ -460,7 +470,7 @@ class Backtests:
|
||||
for k in (
|
||||
"id",
|
||||
"client_item_id",
|
||||
"expression",
|
||||
"expression", "alpha_type", "selection", "combo",
|
||||
"settings",
|
||||
"attempt_id",
|
||||
"platform_status",
|
||||
@@ -610,7 +620,8 @@ class Backtests:
|
||||
source=Source.model_validate({**run.source, "parent_run_id": run.id}),
|
||||
candidates=[
|
||||
Candidate(
|
||||
client_item_id=r.client_item_id, expression=r.expression, settings=r.settings
|
||||
client_item_id=r.client_item_id, expression=r.expression, settings=r.settings,
|
||||
alpha_type=r.alpha_type, selection=r.selection, combo=r.combo
|
||||
)
|
||||
for r in selected
|
||||
],
|
||||
|
||||
@@ -91,27 +91,29 @@ class Business:
|
||||
else None,
|
||||
}
|
||||
|
||||
async def get_alpha_facets(self):
|
||||
async def get_alpha_facets(self, management_scope=None):
|
||||
from .schemas import AlphaFilters
|
||||
ids = list_statement(AlphaFilters(management_scope=management_scope)).with_only_columns(Alpha.id)
|
||||
result = {}
|
||||
for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
|
||||
column = getattr(Alpha, key)
|
||||
result[key] = list(
|
||||
(
|
||||
await self.db.scalars(
|
||||
select(column).where(column.is_not(None)).distinct().order_by(column)
|
||||
select(column).where(column.is_not(None), Alpha.id.in_(ids)).distinct().order_by(column)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
result["tags"] = list(
|
||||
(await self.db.scalars(select(ResearchTag.tag).distinct().order_by(ResearchTag.tag))).all()
|
||||
(await self.db.scalars(select(ResearchTag.tag).where(ResearchTag.alpha_id.in_(ids)).distinct().order_by(ResearchTag.tag))).all()
|
||||
)
|
||||
result["total"] = await self.db.scalar(select(func.count()).select_from(Alpha))
|
||||
result["total"] = await self.db.scalar(select(func.count()).select_from(Alpha).where(Alpha.id.in_(ids)))
|
||||
result["favorites"] = await self.db.scalar(
|
||||
select(func.count()).select_from(Research).where(Research.favorite.is_(True))
|
||||
select(func.count()).select_from(Research).where(Research.favorite.is_(True), Research.alpha_id.in_(ids))
|
||||
)
|
||||
result["last_sync"] = await self.db.scalar(select(func.max(Alpha.synced_at)))
|
||||
result["last_sync"] = await self.db.scalar(select(func.max(Alpha.synced_at)).where(Alpha.id.in_(ids)))
|
||||
result["source"] = sorted(
|
||||
{kind for kinds in (await source_kinds(self.db)).values() for kind in kinds}
|
||||
{kind for kinds in (await source_kinds(self.db, list(await self.db.scalars(ids)))).values() for kind in kinds}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ def setting_rows(data):
|
||||
for key in (
|
||||
"decay", "truncation", "pasteurization", "unitHandling",
|
||||
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
|
||||
"selectionHandling", "selectionLimit", "componentActivation",
|
||||
):
|
||||
definition = children.get(key)
|
||||
if not isinstance(definition, dict):
|
||||
@@ -200,11 +201,15 @@ class ResearchMetadata:
|
||||
raise HTTPException(502, "算子分页提前结束")
|
||||
raise HTTPException(502, "算子分页超过本地限制,未发布新快照")
|
||||
|
||||
async def operators(self, q="", category=None, favorite=False, limit=25, offset=0):
|
||||
async def operators(self, q="", category=None, favorite=False, limit=25, offset=0, stage=None):
|
||||
snapshot = await self.get("operators")
|
||||
notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))}
|
||||
rows = []
|
||||
for item in snapshot["content"].get("items", []):
|
||||
scopes = item.get("scope") or []
|
||||
scopes = scopes if isinstance(scopes, list) else [scopes]
|
||||
if stage and stage.upper() not in [str(s).upper() for s in scopes]:
|
||||
continue
|
||||
note = notes.get(item["name"])
|
||||
if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or (
|
||||
category and item["category"] != category
|
||||
|
||||
@@ -18,11 +18,12 @@ async def operators(
|
||||
q: str = "",
|
||||
category: str | None = None,
|
||||
favorite: bool = False,
|
||||
stage: str | None = None,
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await ResearchMetadata(db).operators(q, category, favorite, limit, offset)
|
||||
return await ResearchMetadata(db).operators(q, category, favorite, limit, offset, stage)
|
||||
|
||||
|
||||
@router.post("/operators/refresh")
|
||||
|
||||
+5
-1
@@ -265,6 +265,10 @@ class Runner:
|
||||
await sync_catalog(self, job_id, payload)
|
||||
elif kind in ("full_sync", "daily_sync"):
|
||||
await self.sync_all(job_id)
|
||||
elif kind == "super_selection_preview":
|
||||
from .superalpha.jobs import run_selection
|
||||
|
||||
await run_selection(self, job_id, payload)
|
||||
elif kind == "submission_check":
|
||||
from .submission import run_check
|
||||
|
||||
@@ -302,7 +306,7 @@ class Runner:
|
||||
await self.checkpoint(
|
||||
job_id,
|
||||
{
|
||||
"status": "waiting_connection" if waiting or (kind == "catalog_full_sync" and exc.code == "network_error") else "failed",
|
||||
"status": "waiting_connection" if waiting or (kind in ("catalog_full_sync", "super_selection_preview") and exc.code == "network_error") else "failed",
|
||||
"error": str(exc),
|
||||
"next_retry_at": None,
|
||||
},
|
||||
|
||||
+5
-3
@@ -6,7 +6,7 @@ import io
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -56,6 +56,7 @@ from .schemas import (
|
||||
)
|
||||
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
||||
from .submission import router as submission_router
|
||||
from .superalpha.routes import router as superalpha_router
|
||||
|
||||
|
||||
def account_output(account, client, settings):
|
||||
@@ -344,9 +345,9 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
return await Business(db).search_alphas(filters)
|
||||
|
||||
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"])
|
||||
async def facets():
|
||||
async def facets(management_scope: Literal["super", "non_super"] | None = None):
|
||||
async with sessions() as db:
|
||||
return await Business(db).get_alpha_facets()
|
||||
return await Business(db).get_alpha_facets(management_scope)
|
||||
|
||||
@api.get(
|
||||
"/alphas/export",
|
||||
@@ -487,6 +488,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(home_information_router)
|
||||
app.include_router(backtest_router)
|
||||
app.include_router(superalpha_router)
|
||||
app.include_router(api)
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(preparations_router)
|
||||
|
||||
@@ -18,12 +18,28 @@ from ..models import MCPAudit, now
|
||||
from ..research.serialization import encode_snapshot
|
||||
from ..research_access import contracts as c
|
||||
from ..research_access.service import ResearchAccess, ResearchError
|
||||
from ..superalpha import contracts as sc
|
||||
|
||||
# Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
|
||||
TOOLS = {
|
||||
"search_superalpha_plans": (sc.PlanSearch, "super_plans", "research:read", "分页查找 Super Alpha 研究方案。"),
|
||||
"get_superalpha_plan": (sc.PlanReference, "super_plan", "research:read", "读取指定方案版本或固定构造记录;不发起回测。"),
|
||||
"save_superalpha_plan": (sc.PlanSave, "save_super_plan", "research:write", "保存调用方构造的 Selection/Combo 参数方案;更新须携带版本,支持幂等。不调用模型或回测。"),
|
||||
"preview_superalpha_selection": (sc.SelectionPreview, "preview_super_selection", "research:refresh", "主动预览展开后的 Selection;异步返回 job_id,用 get_refresh_job 查进度、get_superalpha_selection 查组件。预览不是实际回测组件。"),
|
||||
"get_superalpha_selection": (sc.SelectionReference, "super_selection", "research:read", "分页读取组件预览及完整性、时间、警告;缺失不自动刷新。"),
|
||||
"build_superalpha_candidates": (sc.BuildCandidates, "build_super_candidates", "research:write", "按方案版本或内联方案进行全量展开/固定种子采样;保存固定候选及来源,不执行回测。将 candidates 与 submit_source 交给 submit_backtests;超过100项按分页读取固定记录。"),
|
||||
"search_superalphas": (sc.SuperAlphaSearch, "super_alphas", "research:read", "分页查询本地已导入的 SUPER 成果,固定 SUPER 范围;不自动同步。"),
|
||||
"get_superalpha": (sc.AlphaReference, "super_alpha", "research:read", "读取已导入 SUPER 的 Selection/Combo、指标、组件证据、Description 和研究来源。"),
|
||||
"get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"),
|
||||
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
|
||||
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
|
||||
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
|
||||
"search_research_templates": (c.TemplateSearch, "templates", "research:read", "分页搜索模板工坊的模板与最新版本,不执行研究。"),
|
||||
"get_research_template": (c.TemplateRead, "template", "research:read", "读取模板内容、字段定义和来源;新增版本前核对最新版本。"),
|
||||
"create_research_template_version": (c.CreateTemplateVersion, "create_template_version", "research:write", "为已有模板新增不可变版本。先读取模板,携带 template_id、expected_version、完整 template、hypothesis 和 idempotency_key。source_item_ids 可选,提供时须为真实、已完成采集的回测项。版本冲突须重新读取;不调用模型、不回测。"),
|
||||
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "保存调用方编写的模板,不要求已有回测结果。提供 template、hypothesis、唯一名称和 idempotency_key;source_item_ids 可选,提供时须为真实、已完成采集的回测项。template 使用 {name} 占位符及对应 variables,字段定义只需类型和描述;空字段 values 由展开时的数据准备绑定,其他空参数须补充 values 或直接写入表达式。仅保存模板,不调用模型、不展开、不执行回测。"),
|
||||
"expand_research_template": (c.TemplateExpansion, "expand_template", "research:write", "按 template_id/version、preparation_refs 和完整 settings 生成固定候选集合,支持全组合或固定 seed 随机采样。仅检查语法和数据准备/回测参数组合一致性,无逐行校验状态,不回测。返回首25项和 experiment_id,更多候选用 get_template_candidates 分页读取;用户授权后按明确 candidate_ids 调用 start_template_backtest。重试复用 idempotency_key。"),
|
||||
"get_template_candidates": (c.TemplateCandidates, "template_candidates", "research:read", "分页读取固定模板候选集合的表达式、参数、候选 ID、模板版本、数据准备及关联回测;每页最多100条,total 不是当前页数量。"),
|
||||
"start_template_backtest": (c.SubmitTemplateBacktest, "start_template_backtest", "backtests:execute", "对用户已授权的模板候选集合执行批量回测。提供 experiment_id、明确的 candidate_ids 和 idempotency_key;服务端使用保存的表达式和参数并保留来源,不接受重写输入。不需要另建预览,不重新校验类型或平台可用性。相同请求重试返回同一运行;新幂等键会创建新的回测,包括重复候选。立即返回运行 ID,结果另行查询。"),
|
||||
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
|
||||
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
|
||||
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
|
||||
@@ -36,7 +52,7 @@ TOOLS = {
|
||||
"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 按完整输入精确匹配,不推断数学等价。"),
|
||||
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
||||
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的 REGULAR/SUPER 固定批次,自动留痕并立即返回运行 ID;SUPER 使用 selection/combo 和专属设置,逐条模拟。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
|
||||
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
|
||||
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
|
||||
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
|
||||
@@ -57,7 +73,7 @@ class MCPResearchServer:
|
||||
self.mutation_lock = asyncio.Lock()
|
||||
self.server = Server("wq-alpha-research", version="1.0.0", on_list_tools=self.list_tools,
|
||||
on_call_tool=self.call_tool,
|
||||
instructions="自由探索,直接固定候选回测,无需先建研究资产。工具不安排定时研究;结果按运行 ID 查询。")
|
||||
instructions="自由探索,直接固定候选回测,无需先建研究资产。使用已有模板时,expand_research_template 生成候选,get_template_candidates 分页核对,获得用户授权后 start_template_backtest 执行;无需额外预览或结果评估步骤。工具不安排定时研究;结果按运行 ID 查询。")
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
host = urlsplit(settings.public_origin).netloc
|
||||
@@ -72,8 +88,8 @@ class MCPResearchServer:
|
||||
return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
|
||||
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
|
||||
readOnlyHint=scope == "research:read", destructiveHint=method == "control",
|
||||
idempotentHint=method in {"submit", "control", "create_template"} or scope == "research:read",
|
||||
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate"}))
|
||||
idempotentHint=method in {"submit", "control", "create_template", "create_template_version", "save_super_plan", "build_super_candidates", "expand_template", "start_template_backtest"} or scope == "research:read",
|
||||
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection", "start_template_backtest"}))
|
||||
for name, (schema, method, scope, description) in TOOLS.items()
|
||||
if scope in principal.scopes and "research:read" in principal.scopes])
|
||||
|
||||
@@ -101,7 +117,7 @@ class MCPResearchServer:
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
args = schema.model_validate(arguments)
|
||||
async with asyncio.timeout(30 if method in {"refresh", "metadata"} else None):
|
||||
async with asyncio.timeout(30 if method in {"refresh", "metadata", "pyramid_distribution"} else None):
|
||||
data = encode_snapshot(await getattr(access, method)(args))
|
||||
data.setdefault("_meta", {"schema_version": 1, "observed_at": now().isoformat(),
|
||||
"nulls": "null 表示来源未提供,不等于零", "source": "system"})
|
||||
@@ -126,7 +142,7 @@ class MCPResearchServer:
|
||||
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
|
||||
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
|
||||
request_id=fingerprint({"request_id": request_id}), input_digest=digest,
|
||||
business_id=data.get("backtest_run_id", data.get("job_id", data.get("template_id"))),
|
||||
business_id=data.get("backtest_run_id", data.get("job_id", data.get("experiment_id", data.get("template_id", data.get("id"))))),
|
||||
result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
|
||||
if not error:
|
||||
if access.wake == "backtests":
|
||||
|
||||
@@ -301,6 +301,9 @@ class BacktestItem(Base):
|
||||
client_item_id: Mapped[str] = mapped_column(String(100))
|
||||
ordinal: Mapped[int] = mapped_column(Integer)
|
||||
expression: Mapped[str] = mapped_column(Text)
|
||||
alpha_type: Mapped[str] = mapped_column(String(20), default="REGULAR", server_default="REGULAR", index=True)
|
||||
selection: Mapped[str | None] = mapped_column(Text)
|
||||
combo: Mapped[str | None] = mapped_column(Text)
|
||||
settings: Mapped[dict] = mapped_column(JSON)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), index=True)
|
||||
platform_status: Mapped[str] = mapped_column(String(30), default="pending")
|
||||
@@ -322,6 +325,25 @@ class BacktestResult(Base):
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class SuperSelectionSnapshot(Base):
|
||||
"""Immutable platform component evidence; previews never replace actual components."""
|
||||
|
||||
__tablename__ = "super_selection_snapshots"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
job_id: Mapped[str | None] = mapped_column(ForeignKey("sync_jobs.id"), unique=True)
|
||||
item_id: Mapped[str | None] = mapped_column(ForeignKey("backtest_items.id"), unique=True)
|
||||
source: Mapped[str] = mapped_column(String(20))
|
||||
request: Mapped[dict] = mapped_column(JSON)
|
||||
request_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
component_hash: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
components: Mapped[list] = mapped_column(JSON, default=list)
|
||||
raw: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
total: Mapped[int | None] = mapped_column(Integer)
|
||||
warnings: Mapped[list] = mapped_column(JSON, default=list)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
|
||||
class BacktestEvent(Base):
|
||||
__tablename__ = "backtest_events"
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("backtest_runs.id"), primary_key=True)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Read-only Pyramid endpoint probe: python -m app.probe_pyramids.
|
||||
|
||||
Uses the project's configured account and WqClient without changing stored data.
|
||||
A standalone process authenticates separately; it cannot inherit a running
|
||||
server's in-memory cookies. Authentication responses and secrets are never printed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from .config import Settings
|
||||
from .db import create_database
|
||||
from .models import Account
|
||||
from .security import cipher
|
||||
from .worldquant import WqClient, WqError
|
||||
|
||||
PATHS = (
|
||||
"/users/self/activities/pyramid-alphas",
|
||||
"/users/self/pyramid/alphas",
|
||||
"/activities/pyramid-alphas",
|
||||
"/pyramid/alphas",
|
||||
)
|
||||
|
||||
|
||||
async def probe(client):
|
||||
"""Probe fixed same-origin GET paths using an authenticated WqClient.
|
||||
|
||||
Returns status and successful JSON for each path; stops on session expiry.
|
||||
Transport failures propagate to the caller without exposing request details.
|
||||
"""
|
||||
if not client.authenticated:
|
||||
raise WqError("请先连接 WorldQuant", "disconnected")
|
||||
results = []
|
||||
for path in PATHS:
|
||||
response = await client.client.get(path)
|
||||
result = {"path": path, "status": response.status_code}
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
result["data"] = response.json()
|
||||
except ValueError:
|
||||
result["error"] = "invalid_json"
|
||||
results.append(result)
|
||||
if response.status_code in (401, 429):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
async def main():
|
||||
settings = Settings()
|
||||
client = WqClient(settings)
|
||||
engine = None
|
||||
try:
|
||||
if settings.wq_email:
|
||||
email, password = settings.wq_email, settings.wq_password.get_secret_value()
|
||||
else:
|
||||
engine, sessions = create_database(settings.database_url)
|
||||
async with sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
if not account or not account.email or not account.password_encrypted:
|
||||
raise WqError("未配置平台凭据", "disconnected")
|
||||
email = account.email
|
||||
password = cipher(settings).decrypt(account.password_encrypted.encode()).decode()
|
||||
await client.authenticate(email, password)
|
||||
print(json.dumps(await probe(client), ensure_ascii=False, indent=2))
|
||||
except WqError as exc:
|
||||
print(json.dumps({"error": exc.code}, ensure_ascii=False))
|
||||
return 1
|
||||
except Exception as exc:
|
||||
# Connection/config errors can contain credentials; print only the type.
|
||||
print(json.dumps({"error": type(exc).__name__}))
|
||||
return 1
|
||||
finally:
|
||||
await client.close()
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -51,7 +51,10 @@ class Assets:
|
||||
}
|
||||
|
||||
async def save(self, body, asset_id=None, provenance=None):
|
||||
from ..superalpha.contracts import PlanSpec
|
||||
|
||||
schema = {
|
||||
"superalpha_plan": PlanSpec,
|
||||
"template": TemplateSpec,
|
||||
"feature": FeatureSpec,
|
||||
"view": ViewSpec,
|
||||
|
||||
@@ -7,14 +7,22 @@ from collections import defaultdict
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source
|
||||
from ..backtests.contracts import (
|
||||
Candidate,
|
||||
DraftInput,
|
||||
PreviewInput,
|
||||
SimulationSettings,
|
||||
Source,
|
||||
StartInput,
|
||||
fingerprint,
|
||||
)
|
||||
from ..backtests.service import Backtests, uid
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
from ..catalog.service import Catalog
|
||||
from ..models import Alpha, BacktestRun, CatalogResource, ResearchExperiment
|
||||
from ..models import Account, Alpha, BacktestPreview, BacktestRun, CatalogResource, ResearchExperiment
|
||||
from ..preparations.service import Preparations
|
||||
from .assets import Assets
|
||||
from .expressions import GROUPS, analyze, expand
|
||||
from .expressions import GROUPS, ExpressionError, Parser, analyze, expand
|
||||
from .serialization import encode_snapshot as jsonable_encoder
|
||||
from .workspace_contracts import TemplateSpec
|
||||
|
||||
@@ -45,7 +53,7 @@ class Experiments:
|
||||
self.catalog = Catalog(db)
|
||||
self.assets = Assets(db)
|
||||
|
||||
async def inputs(self, ids, scope=None):
|
||||
async def inputs(self, ids, scope=None, *, check_types=True):
|
||||
if len(set(ids)) != len(ids):
|
||||
raise HTTPException(422, "输入快照重复")
|
||||
snapshots = [await self.catalog.input(input_id) for input_id in ids]
|
||||
@@ -56,7 +64,7 @@ class Experiments:
|
||||
for name, kind in item["field_types"].items():
|
||||
if name not in item["field_ids"]:
|
||||
continue
|
||||
if name in fields and fields[name] != kind:
|
||||
if check_types and name in fields and fields[name] != kind:
|
||||
raise HTTPException(422, f"字段 {name} 在不同快照中类型不一致")
|
||||
fields[name] = kind
|
||||
return snapshots, fields
|
||||
@@ -138,9 +146,9 @@ class Experiments:
|
||||
asset = await self.assets.get(body.asset_id, body.version, "template") if body.asset_id else None
|
||||
template = TemplateSpec.model_validate(asset["content"]) if asset else body.template
|
||||
scope = scope_of(body.settings)
|
||||
if template.scope and template.scope.model_dump() != scope:
|
||||
raise HTTPException(422, "模板适用范围与候选设置不同")
|
||||
snapshots, fields = await self.inputs(body.input_ids, scope)
|
||||
snapshots, fields = await self.inputs(body.input_ids, scope, check_types=kind != "template")
|
||||
if kind == "template" and not snapshots:
|
||||
raise HTTPException(422, "请先选择数据准备")
|
||||
parents = (
|
||||
parent_snapshots
|
||||
if parent_snapshots is not None
|
||||
@@ -148,31 +156,53 @@ class Experiments:
|
||||
)
|
||||
variables = {}
|
||||
for name, variable in template.variables.items():
|
||||
values = variable.values
|
||||
# Empty field definitions bind only to the selected immutable input scope.
|
||||
# Existing explicit domains remain restrictions and are never silently widened.
|
||||
if variable.kind == "field":
|
||||
for value in variable.values:
|
||||
if fields.get(str(value)) != variable.field_type:
|
||||
if not values:
|
||||
values = sorted(field for field, kind in fields.items() if kind == variable.field_type)
|
||||
if not values:
|
||||
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
|
||||
for value in values:
|
||||
if kind != "template" and fields.get(str(value)) != variable.field_type:
|
||||
raise HTTPException(422, f"变量 {name} 的字段 {value} 不在固定输入中或类型不符")
|
||||
if variable.kind == "group" and any(
|
||||
if kind != "template" and variable.kind == "group" and any(
|
||||
str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values
|
||||
):
|
||||
raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实")
|
||||
if not values:
|
||||
raise HTTPException(422, f"变量 {name} 缺少候选取值,请通过模板接口补充,或将固定参数直接写入表达式")
|
||||
variables[name] = [
|
||||
json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in variable.values
|
||||
json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in values
|
||||
]
|
||||
try:
|
||||
expanded = expand(template.expression, variables, body.mode, body.limit, body.seed)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from None
|
||||
operators_snapshot = await ResearchMetadata(self.db).get("operators")
|
||||
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
|
||||
setting_errors, settings_snapshot = await self.settings_check(body.settings)
|
||||
availability = await self.field_evidence(scope, fields)
|
||||
validation_evidence = {}
|
||||
if kind != "template":
|
||||
operators_snapshot = await ResearchMetadata(self.db).get("operators")
|
||||
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
|
||||
setting_errors, settings_snapshot = await self.settings_check(body.settings)
|
||||
availability = await self.field_evidence(scope, fields)
|
||||
validation_evidence = {
|
||||
"field_availability": availability,
|
||||
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
|
||||
"operators_snapshot": operators_snapshot,
|
||||
"settings_snapshot": settings_snapshot,
|
||||
}
|
||||
candidates = []
|
||||
for index, item in enumerate(expanded["items"]):
|
||||
validation = self.validate(item["expression"], fields, operators, scope, availability)
|
||||
validation["availability"].extend(setting_errors)
|
||||
if setting_errors and validation["status"] == "valid":
|
||||
validation["status"] = "needs_review"
|
||||
findings = {}
|
||||
if kind == "template":
|
||||
self.check_syntax(item["expression"], f"候选 {index + 1}")
|
||||
else:
|
||||
validation = self.validate(item["expression"], fields, operators, scope, availability)
|
||||
validation["availability"].extend(setting_errors)
|
||||
if setting_errors and validation["status"] == "valid":
|
||||
validation["status"] = "needs_review"
|
||||
findings["validation"] = validation
|
||||
candidates.append(
|
||||
{
|
||||
**Candidate(
|
||||
@@ -180,7 +210,7 @@ class Experiments:
|
||||
).model_dump(mode="json"),
|
||||
"bindings": item["bindings"],
|
||||
"input_ids": list(body.input_ids),
|
||||
"validation": validation,
|
||||
**findings,
|
||||
"changes": [
|
||||
self.diff(parent.get("expression", ""), item["expression"])
|
||||
for parent in parents
|
||||
@@ -190,16 +220,21 @@ class Experiments:
|
||||
)
|
||||
evidence = {
|
||||
"template": asset or {"content": template.model_dump(mode="json")},
|
||||
"field_availability": availability,
|
||||
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
|
||||
"combination_count": expanded["combination_count"],
|
||||
"seed": expanded["seed"],
|
||||
"operators_snapshot": operators_snapshot,
|
||||
"settings_snapshot": settings_snapshot,
|
||||
**validation_evidence,
|
||||
**(extra_evidence or {}),
|
||||
}
|
||||
return await self.save(template.name, kind, body.hypothesis, snapshots, parents, candidates, evidence)
|
||||
|
||||
@staticmethod
|
||||
def check_syntax(expression, label="表达式"):
|
||||
"""Reject unsupported syntax before persistence; platform semantics are not inferred."""
|
||||
try:
|
||||
Parser(expression).parse()
|
||||
except (ExpressionError, RecursionError) as exc:
|
||||
raise HTTPException(422, f"{label}语法错误:{exc}") from None
|
||||
|
||||
@staticmethod
|
||||
def diff(before, after):
|
||||
return [
|
||||
@@ -284,6 +319,26 @@ class Experiments:
|
||||
}
|
||||
)
|
||||
|
||||
async def template_candidates(self, experiment_id, limit=25, offset=0):
|
||||
"""Read a bounded page of stored template candidates and their immutable references."""
|
||||
experiment = await self.get(experiment_id)
|
||||
if experiment["kind"] != "template":
|
||||
raise HTTPException(422, "此入口仅用于模板候选集合")
|
||||
candidates = experiment["candidates"]
|
||||
template = experiment["evidence"].get("template", {})
|
||||
return {
|
||||
"id": experiment_id, "experiment_id": experiment_id,
|
||||
"name": experiment["name"], "archived": experiment["archived"],
|
||||
"template": {k: template.get(k) for k in ("id", "version", "name")},
|
||||
"inputs": [{k: item.get(k) for k in ("id", "preparation_id", "preparation_version", "scope")}
|
||||
for item in experiment["inputs"]],
|
||||
"items": [{k: c[k] for k in ("client_item_id", "expression", "settings", "alpha_type", "bindings") if k in c}
|
||||
for c in candidates[offset:offset + limit]],
|
||||
"total": len(candidates), "limit": limit, "offset": offset,
|
||||
"has_more": offset + limit < len(candidates),
|
||||
"backtest_run_ids": experiment["backtest_run_ids"],
|
||||
}
|
||||
|
||||
async def archive(self, experiment_id):
|
||||
"""Hide an immutable experiment; backtests and lineage must still resolve it.
|
||||
|
||||
@@ -297,7 +352,8 @@ class Experiments:
|
||||
raise HTTPException(404, "研究实验不存在")
|
||||
return {"ok": True}
|
||||
|
||||
async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
|
||||
async def backtest_input(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
|
||||
"""Build the complete fixed selection and enforce its domain checks."""
|
||||
experiment = await self.get(experiment_id)
|
||||
candidates = experiment["candidates"]
|
||||
if candidate_ids is not None:
|
||||
@@ -307,37 +363,90 @@ class Experiments:
|
||||
candidates = [item for item in candidates if item["client_item_id"] in chosen]
|
||||
if len(candidates) != len(chosen):
|
||||
raise HTTPException(422, "选择包含未知候选")
|
||||
else:
|
||||
elif experiment["kind"] != "template":
|
||||
candidates = [item for item in candidates if item["validation"]["status"] == "valid"]
|
||||
if not candidates or any(item["validation"]["status"] != "valid" for item in candidates):
|
||||
if not candidates:
|
||||
raise HTTPException(422, "请至少选择一条候选")
|
||||
if experiment["kind"] != "template" and any(item["validation"]["status"] != "valid" for item in candidates):
|
||||
raise HTTPException(422, "候选存在语法、类型或可用性问题,请先解决;至少保留一条已核实候选")
|
||||
inputs = experiment["inputs"]
|
||||
return await Backtests(self.db).preview(
|
||||
PreviewInput(
|
||||
inline=DraftInput(
|
||||
name=experiment["name"],
|
||||
source=Source(
|
||||
kind=source_kind or experiment["kind"],
|
||||
reference=reference or experiment_id,
|
||||
research_id=experiment_id,
|
||||
input_snapshot_ids=[i["id"] for i in inputs],
|
||||
input_snapshot_id=inputs[0]["id"] if len(inputs) == 1 else None,
|
||||
hypothesis=experiment["hypothesis"][:2000],
|
||||
),
|
||||
candidates=[
|
||||
Candidate.model_validate(
|
||||
{
|
||||
key: item[key]
|
||||
for key in ("client_item_id", "expression", "settings", "alpha_type")
|
||||
}
|
||||
)
|
||||
for item in candidates
|
||||
],
|
||||
)
|
||||
if experiment["kind"] == "template":
|
||||
# Historical collections follow the same syntax/scope contract; old row findings are irrelevant.
|
||||
for candidate in candidates:
|
||||
self.check_syntax(candidate["expression"], candidate["client_item_id"])
|
||||
scope = scope_of(SimulationSettings.model_validate(candidate["settings"]))
|
||||
if not inputs or any(item["scope"] != scope for item in inputs):
|
||||
raise HTTPException(422, "数据准备与回测参数组合不一致,请重新生成候选集合")
|
||||
return DraftInput(
|
||||
name=experiment["name"],
|
||||
source=Source(
|
||||
kind=source_kind or experiment["kind"],
|
||||
reference=reference or experiment_id,
|
||||
research_id=experiment_id,
|
||||
input_snapshot_ids=[i["id"] for i in inputs],
|
||||
input_snapshot_id=inputs[0]["id"] if len(inputs) == 1 else None,
|
||||
hypothesis=experiment["hypothesis"][:2000],
|
||||
),
|
||||
preserve_source=True,
|
||||
candidates=[
|
||||
Candidate.model_validate(
|
||||
{
|
||||
key: item[key]
|
||||
for key in ("client_item_id", "expression", "settings", "alpha_type")
|
||||
}
|
||||
)
|
||||
for item in candidates
|
||||
],
|
||||
)
|
||||
|
||||
async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None, *, backtests=None):
|
||||
draft = await self.backtest_input(experiment_id, candidate_ids, source_kind, reference)
|
||||
return await (backtests or Backtests(self.db)).preview(PreviewInput(inline=draft), preserve_source=True)
|
||||
|
||||
async def start_template_backtest(self, experiment_id, body, *, backtests=None, confirmed_preview=None):
|
||||
"""Start the explicitly selected immutable collection in the caller's transaction.
|
||||
|
||||
Account locking covers preview creation as well as run creation, so concurrent
|
||||
retries share one run. Reusing a key for another collection/selection raises 409.
|
||||
The caller must wake the runner only after committing this transaction.
|
||||
"""
|
||||
chosen = set(body.candidate_ids)
|
||||
if len(chosen) != len(body.candidate_ids):
|
||||
raise HTTPException(422, "候选选择包含重复项")
|
||||
await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||
previous = await self.db.scalar(select(BacktestRun).where(BacktestRun.idempotency_key == body.idempotency_key))
|
||||
if previous:
|
||||
saved = await self.db.get(BacktestPreview, previous.preview_id)
|
||||
if previous.source.get("research_id") != experiment_id or chosen != {
|
||||
c["client_item_id"] for c in saved.candidates
|
||||
}:
|
||||
raise HTTPException(409, "幂等键已用于另一候选集合或选择")
|
||||
return await Backtests(self.db).run(previous.id)
|
||||
experiment = await self.get(experiment_id)
|
||||
if experiment["kind"] != "template":
|
||||
raise HTTPException(422, "此入口仅用于模板候选集合")
|
||||
if experiment["archived"]:
|
||||
raise HTTPException(409, "候选集合已删除")
|
||||
service = backtests or Backtests(self.db)
|
||||
if confirmed_preview is None:
|
||||
preview = await self.preview(experiment_id, body.candidate_ids, backtests=service)
|
||||
else:
|
||||
# Approval authorizes all persisted candidates, not the first display page.
|
||||
draft = (await self.backtest_input(experiment_id, body.candidate_ids)).model_dump(mode="json")
|
||||
expected = fingerprint({"candidates": draft["candidates"], "source": draft["source"]})
|
||||
saved = await self.db.get(BacktestPreview, confirmed_preview["preview_id"])
|
||||
if (
|
||||
saved is None
|
||||
or saved.version != confirmed_preview["version"]
|
||||
or saved.digest != confirmed_preview["digest"]
|
||||
or saved.digest != expected
|
||||
or fingerprint({"candidates": saved.candidates, "source": saved.source}) != expected
|
||||
):
|
||||
raise HTTPException(409, "回测候选与确认内容不匹配,请重新确认")
|
||||
preview = confirmed_preview
|
||||
return await service.start(StartInput(
|
||||
preview_id=preview["preview_id"], version=preview["version"], idempotency_key=body.idempotency_key,
|
||||
))
|
||||
|
||||
async def setting_variants(self, body, *, parent_snapshot=None, extra_evidence=None, kind="variant"):
|
||||
parents = (
|
||||
[parent_snapshot] if parent_snapshot is not None else await self.parents([body.alpha_id], [])
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from ..backtests.contracts import RunOutput
|
||||
from ..security import require_auth
|
||||
from .assets import Assets
|
||||
from .comparisons import compare
|
||||
@@ -22,6 +23,7 @@ from .workspace_contracts import (
|
||||
ImportCommit,
|
||||
ImportPreview,
|
||||
SettingVariants,
|
||||
TemplateBacktest,
|
||||
WorkflowSpec,
|
||||
)
|
||||
|
||||
@@ -149,6 +151,14 @@ async def preview(experiment_id: str, body: ExperimentPreview, request: Request)
|
||||
return await Experiments(db).preview(experiment_id, body.candidate_ids)
|
||||
|
||||
|
||||
@router.post("/experiments/{experiment_id}/backtest", status_code=202, response_model=RunOutput)
|
||||
async def template_backtest(experiment_id: str, body: TemplateBacktest, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
result = await Experiments(db).start_template_backtest(experiment_id, body)
|
||||
request.app.state.runner.backtests.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/variants/settings", status_code=201)
|
||||
async def settings_variants(body: SettingVariants, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
|
||||
@@ -522,7 +522,8 @@ class ResearchRuntime:
|
||||
"type": "candidates",
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [
|
||||
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
|
||||
c["client_item_id"] for c in experiment["candidates"]
|
||||
if experiment["kind"] == "template" or c["validation"]["status"] == "valid"
|
||||
],
|
||||
}
|
||||
ids = step.output["candidate_ids"]
|
||||
|
||||
@@ -11,12 +11,13 @@ from ..preparations.contracts import PreparationReference
|
||||
from ..schemas import Contract
|
||||
from .expressions import IDENTIFIER, PLACEHOLDER, normalize_template
|
||||
|
||||
AssetKind = Literal["template", "feature", "view", "workflow"]
|
||||
AssetKind = Literal["template", "feature", "view", "workflow", "superalpha_plan"]
|
||||
|
||||
|
||||
class Variable(Contract):
|
||||
kind: Literal["field", "operator", "integer", "number", "group", "string", "fragment"]
|
||||
values: list[str | int | float] = Field(min_length=1, max_length=10000)
|
||||
values: list[str | int | float] = Field(default_factory=list, max_length=10000)
|
||||
description: str = Field(default="", max_length=3000)
|
||||
field_type: Literal["MATRIX", "VECTOR", "GROUP"] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -44,7 +45,6 @@ class TemplateSpec(Contract):
|
||||
description: str = Field(default="", max_length=10000)
|
||||
expression: str = Field(min_length=1, max_length=20000)
|
||||
variables: dict[str, Variable] = Field(default_factory=dict, max_length=100)
|
||||
scope: Scope | None = None
|
||||
category: Literal["template", "fragment"] = "template"
|
||||
|
||||
@field_validator("expression")
|
||||
@@ -144,6 +144,11 @@ class ExperimentPreview(Contract):
|
||||
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class TemplateBacktest(Contract):
|
||||
candidate_ids: list[str] = Field(min_length=1, max_length=10000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class EvaluationRules(Contract):
|
||||
version: Literal["research-v1"] = "research-v1"
|
||||
sharpe_min: float = Field(default=1.0, allow_inf_nan=False)
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"""Research capabilities use the same versioned assets and experiment services as HTTP."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import Field
|
||||
|
||||
from ..ai.capabilities import Capability
|
||||
from ..backtests.ai_tools import wake_backtests
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
from ..schemas import Contract
|
||||
from .assets import Assets
|
||||
from .evaluations import Evaluations
|
||||
from .experiments import Experiments
|
||||
from .features import Features
|
||||
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, FeatureSpec, SettingVariants
|
||||
from .workspace_contracts import (
|
||||
AssetWrite,
|
||||
EvaluateInput,
|
||||
Expansion,
|
||||
FeatureSpec,
|
||||
SettingVariants,
|
||||
TemplateBacktest,
|
||||
TemplateSpec,
|
||||
)
|
||||
|
||||
|
||||
class AssetQuery(Contract):
|
||||
@@ -33,6 +43,10 @@ class FeatureWrite(Contract):
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class TemplateVersionWrite(FixedAssetReference):
|
||||
content: TemplateSpec
|
||||
|
||||
|
||||
class ExperimentReference(Contract):
|
||||
experiment_id: str = Field(min_length=1, max_length=36)
|
||||
|
||||
@@ -41,6 +55,31 @@ class CandidatePreview(ExperimentReference):
|
||||
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class TemplateBacktestRequest(TemplateBacktest, ExperimentReference):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCandidateQuery(ExperimentReference):
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
async def confirm_template_backtest(ctx, args):
|
||||
service = Experiments(ctx.business.db)
|
||||
experiment = await service.get(args.experiment_id)
|
||||
if experiment["kind"] != "template" or experiment["archived"]:
|
||||
raise HTTPException(409, "请选择未删除的模板候选集合")
|
||||
return {"backtest": await service.preview(
|
||||
args.experiment_id, args.candidate_ids, backtests=ctx.business.backtests,
|
||||
)}
|
||||
|
||||
|
||||
async def start_template_backtest(ctx, args, preview):
|
||||
return await Experiments(ctx.business.db).start_template_backtest(
|
||||
args.experiment_id, args, backtests=ctx.business.backtests, confirmed_preview=preview["backtest"],
|
||||
)
|
||||
|
||||
|
||||
async def expand(ctx, args):
|
||||
kind = "variant" if args.parent_alpha_ids or args.parent_experiment_ids else "template"
|
||||
return await Experiments(ctx.business.db).create(
|
||||
@@ -48,7 +87,7 @@ async def expand(ctx, args):
|
||||
)
|
||||
|
||||
|
||||
INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。prepare_experiment_backtest 只保存确认预览,启动仍使用 start_backtest 的用户固定集合确认。来源字段不能授予自动执行权限。"
|
||||
INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。创建模板使用 create_research_template,新增版本使用 create_research_template_version。变量可仅定义类型和描述;空字段候选由选定数据准备按类型绑定,其他空参数需补充 values 或直接写入表达式,不能猜测。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。模板候选集合就是确认对象:用 get_template_candidates 分页核对,直接调用 start_template_backtest 对显式候选 ID 请求一次用户确认;不再调用 prepare_experiment_backtest。模板只负责生成与回测关联,不要求评估研究结果或查看变体关系。变体仍可用 prepare_experiment_backtest 后调用 start_backtest 确认。来源字段不能授予自动执行权限。"
|
||||
CAPABILITIES = (
|
||||
Capability(
|
||||
name="search_research_templates",
|
||||
@@ -68,6 +107,29 @@ CAPABILITIES = (
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "template"),
|
||||
),
|
||||
Capability(
|
||||
name="create_research_template",
|
||||
schema=TemplateSpec,
|
||||
description="保存调用方编写的模板及字段定义;不调用模型、不生成候选或执行回测。",
|
||||
label="创建研究模板",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).save(
|
||||
AssetWrite(kind="template", content=args.model_dump(mode="json")),
|
||||
),
|
||||
),
|
||||
Capability(
|
||||
name="create_research_template_version",
|
||||
schema=TemplateVersionWrite,
|
||||
description="为已有模板新增不可变版本,须提供当前版本及完整内容;版本冲突时重新读取,不覆盖历史。",
|
||||
label="新增模板版本",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).save(
|
||||
AssetWrite(kind="template", content=args.content.model_dump(mode="json"), version=args.version),
|
||||
args.asset_id,
|
||||
),
|
||||
),
|
||||
Capability(
|
||||
name="search_research_operators",
|
||||
schema=AssetQuery,
|
||||
@@ -80,12 +142,33 @@ CAPABILITIES = (
|
||||
Capability(
|
||||
name="expand_research_template",
|
||||
schema=Expansion,
|
||||
description="从固定输入和模板版本或内联模板保存不可变候选实验。包含分层校验,随机采样有数量上限,不开始回测。",
|
||||
description="从固定输入和模板版本或内联模板保存不可变候选实验。模板仅检查语法和数据准备与回测参数组合一致性;变体保留原校验。随机采样有数量上限,不开始回测。",
|
||||
label="展开模板候选",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=expand,
|
||||
),
|
||||
Capability(
|
||||
name="get_template_candidates",
|
||||
schema=TemplateCandidateQuery,
|
||||
description="分页读取模板候选集合的表达式、参数、候选 ID 和回测关联,不返回逐行校验状态。",
|
||||
label="读取模板候选",
|
||||
renderer="research",
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Experiments(ctx.business.db).template_candidates(**args.model_dump()),
|
||||
),
|
||||
Capability(
|
||||
name="start_template_backtest",
|
||||
schema=TemplateBacktestRequest,
|
||||
description="直接对已保存模板集合中的显式候选 ID 请求一次用户确认,确认后批量回测;无需准备额外预览。重试复用幂等键。",
|
||||
label="回测模板候选",
|
||||
renderer="backtest",
|
||||
effect="confirm",
|
||||
preview=confirm_template_backtest,
|
||||
execute=start_template_backtest,
|
||||
after_commit=wake_backtests,
|
||||
refresh=("backtests",),
|
||||
),
|
||||
Capability(
|
||||
name="prepare_setting_variants",
|
||||
schema=SettingVariants,
|
||||
@@ -107,7 +190,7 @@ CAPABILITIES = (
|
||||
Capability(
|
||||
name="prepare_experiment_backtest",
|
||||
schema=CandidatePreview,
|
||||
description="从实验内已校验的固定候选保存回测确认预览,不启动模拟。",
|
||||
description="为变体等研究实验保存回测预览,不启动模拟;模板直接使用 start_template_backtest。",
|
||||
label="准备研究回测",
|
||||
renderer="backtest",
|
||||
effect="prepare",
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from ..backtests.contracts import Candidate, SimulationSettings
|
||||
from ..backtests.contracts import Candidate, SimulationSettings, SuperSimulationSettings
|
||||
from ..catalog.contracts import CatalogFilters, Scope
|
||||
from ..preparations.contracts import PreparationReference
|
||||
from ..research.workspace_contracts import TemplateSpec
|
||||
@@ -20,6 +20,12 @@ class Empty(Contract):
|
||||
pass
|
||||
|
||||
|
||||
class PyramidQuery(Contract):
|
||||
current_date: date = Field(description="用于确定季度的日期,格式 YYYY-MM-DD;自动查询该季度完整起止范围")
|
||||
region: str = Field(min_length=3, max_length=10, pattern=r"^[A-Z]+$")
|
||||
delay: int = Field(ge=0, le=1, strict=True)
|
||||
|
||||
|
||||
class Authentication(Contract):
|
||||
action: Literal["connect", "verify"] = "connect"
|
||||
|
||||
@@ -44,11 +50,26 @@ class CompleteSettings(SimulationSettings):
|
||||
model_config = {"json_schema_extra": {"required": list(SimulationSettings.model_fields)}}
|
||||
|
||||
|
||||
class CompleteSuperSettings(SuperSimulationSettings):
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def complete(cls, value):
|
||||
if isinstance(value, dict) and set(cls.model_fields) - value.keys():
|
||||
raise ValueError("必须提供每项完整 SUPER 设置;先读取 get_research_capabilities")
|
||||
return value
|
||||
|
||||
model_config = {"json_schema_extra": {"required": list(SuperSimulationSettings.model_fields)}}
|
||||
|
||||
|
||||
class DirectCandidate(Candidate):
|
||||
settings: CompleteSettings
|
||||
settings: CompleteSuperSettings | CompleteSettings
|
||||
|
||||
|
||||
class Provenance(Contract):
|
||||
research_id: RunId | None = None
|
||||
superalpha_plan_id: RunId | None = None
|
||||
superalpha_plan_version: int | None = Field(default=None, ge=1)
|
||||
selection_snapshot_ids: list[RunId] = Field(default_factory=list, max_length=100)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
batch_id: str | None = Field(default=None, max_length=200)
|
||||
hypothesis: str | None = Field(default=None, max_length=2000)
|
||||
@@ -95,7 +116,7 @@ class Control(Contract):
|
||||
class CreateTemplate(Contract):
|
||||
template: TemplateSpec
|
||||
hypothesis: str = Field(min_length=1, max_length=10000)
|
||||
source_item_ids: list[RunId] = Field(min_length=1, max_length=20)
|
||||
source_item_ids: list[RunId] = Field(default_factory=list, max_length=20)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
idempotency_key: Identifier
|
||||
|
||||
@@ -112,6 +133,41 @@ class CreateTemplate(Contract):
|
||||
return self
|
||||
|
||||
|
||||
class CreateTemplateVersion(CreateTemplate):
|
||||
template_id: RunId
|
||||
expected_version: int = Field(ge=1)
|
||||
|
||||
|
||||
class TemplateRead(Contract):
|
||||
template_id: RunId
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class TemplateSearch(Page):
|
||||
q: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class TemplateExpansion(Contract):
|
||||
template_id: RunId
|
||||
version: int = Field(ge=1)
|
||||
preparation_refs: list[PreparationReference] = Field(min_length=1, max_length=20)
|
||||
settings: CompleteSettings
|
||||
mode: Literal["all", "random"] = "all"
|
||||
limit: int = Field(default=100, ge=1, le=10000)
|
||||
seed: int = 0
|
||||
idempotency_key: Identifier
|
||||
|
||||
|
||||
class TemplateCandidates(Page):
|
||||
experiment_id: RunId
|
||||
|
||||
|
||||
class SubmitTemplateBacktest(Contract):
|
||||
experiment_id: RunId
|
||||
candidate_ids: list[Identifier] = Field(min_length=1, max_length=10000)
|
||||
idempotency_key: Identifier
|
||||
|
||||
|
||||
class CatalogSearch(Contract):
|
||||
filters: CatalogFilters
|
||||
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
@@ -123,12 +179,18 @@ class Scopes(Contract):
|
||||
|
||||
class SettingOptions(Page):
|
||||
kind: Literal["settings"]
|
||||
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
|
||||
|
||||
|
||||
class Operators(Page):
|
||||
kind: Literal["operators"]
|
||||
q: str = Field(default="", max_length=300)
|
||||
category: str | None = None
|
||||
stage: Literal["REGULAR", "SELECTION", "COMBO"] | None = None
|
||||
|
||||
|
||||
class SuperMetadata(Contract):
|
||||
kind: Literal["superalpha"]
|
||||
|
||||
|
||||
class Availability(Contract):
|
||||
@@ -138,7 +200,7 @@ class Availability(Contract):
|
||||
|
||||
|
||||
class Metadata(Contract):
|
||||
query: Annotated[Scopes | SettingOptions | Operators | Availability, Field(discriminator="kind")]
|
||||
query: Annotated[Scopes | SettingOptions | Operators | Availability | SuperMetadata, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class CatalogRefresh(Contract):
|
||||
@@ -186,6 +248,8 @@ class SubmissionCheck(Contract):
|
||||
|
||||
|
||||
class History(Page):
|
||||
research_id: str | None = Field(default=None, max_length=36)
|
||||
alpha_type: Literal["REGULAR", "SUPER"] | None = None
|
||||
source: str | None = Field(default=None, max_length=100)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
status: str | None = Field(default=None, max_length=30)
|
||||
@@ -218,13 +282,13 @@ class Results(Page):
|
||||
|
||||
class Artifact(Page):
|
||||
item_id: RunId
|
||||
kind: Literal["snapshot", "pnl"]
|
||||
kind: Literal["snapshot", "pnl", "components"]
|
||||
date_from: date | None = None
|
||||
date_to: date | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def dates(self):
|
||||
if self.kind == "snapshot" and (self.date_from or self.date_to):
|
||||
if self.kind != "pnl" and (self.date_from or self.date_to):
|
||||
raise ValueError("日期筛选仅用于 PnL")
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise ValueError("起始日期不能晚于结束日期")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Partition platform category counts without treating missing evidence as zero."""
|
||||
|
||||
from calendar import monthrange
|
||||
|
||||
|
||||
def quarter_period(current_date):
|
||||
"""Return the full calendar quarter containing the supplied date, inclusive."""
|
||||
quarter = (current_date.month - 1) // 3 + 1
|
||||
end_month = quarter * 3
|
||||
start = current_date.replace(month=end_month - 2, day=1)
|
||||
end = current_date.replace(month=end_month, day=monthrange(current_date.year, end_month)[1])
|
||||
return {"quarter": f"{current_date.year}-Q{quarter}",
|
||||
"start_date": start.isoformat(), "end_date": end.isoformat()}
|
||||
|
||||
|
||||
def distribution(raw, region, delay):
|
||||
"""Return three category lists; raise ValueError on incomplete or duplicate data."""
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("pyramids"), list):
|
||||
raise ValueError("平台未提供 Pyramid 分布")
|
||||
groups = {"lit": [], "in_progress": [], "unlit": []}
|
||||
seen = set()
|
||||
for row in raw["pyramids"]:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError("平台 Pyramid 数据格式异常")
|
||||
if row.get("region") != region or row.get("delay") != delay:
|
||||
continue
|
||||
category, count = row.get("category"), row.get("alphaCount")
|
||||
if (not isinstance(category, dict)
|
||||
or not isinstance(category.get("id"), str) or not category["id"]
|
||||
or not isinstance(category.get("name"), str) or not category["name"]
|
||||
or type(count) is not int or count < 0 or category["id"] in seen):
|
||||
raise ValueError("平台分类或计数缺失、非法或重复,不能判定点塔状态")
|
||||
seen.add(category["id"])
|
||||
key = "lit" if count >= 3 else "in_progress" if count > 0 else "unlit"
|
||||
groups[key].append({"category": {"id": category["id"], "name": category["name"]},
|
||||
"alpha_count": count, "remaining": max(0, 3 - count)})
|
||||
if not seen:
|
||||
raise ValueError("平台未返回此 region/delay 的分类,不能认定全部未点亮")
|
||||
for items in groups.values():
|
||||
items.sort(key=lambda item: item["category"]["id"])
|
||||
return groups
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from ..alphas import number, sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
@@ -54,7 +54,7 @@ def item_summary(item, result):
|
||||
("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")}
|
||||
return encode_snapshot({
|
||||
**{k: getattr(item, k) for k in (
|
||||
"id", "run_id", "client_item_id", "expression", "settings", "attempt_id",
|
||||
"id", "run_id", "client_item_id", "expression", "selection", "combo", "alpha_type", "settings", "attempt_id",
|
||||
"platform_status", "collection_status", "persistence_status", "simulation_id", "alpha_id",
|
||||
)},
|
||||
"error": sanitize(item.error), "metrics": metrics,
|
||||
@@ -73,10 +73,12 @@ class EvidenceQueries:
|
||||
query = select(BacktestItem, BacktestResult, BacktestRun).join(
|
||||
BacktestRun, BacktestRun.id == BacktestItem.run_id
|
||||
).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id)
|
||||
for key in ("source", "reference"):
|
||||
for key in ("source", "reference", "research_id"):
|
||||
value = getattr(args, key)
|
||||
if value is not None:
|
||||
query = query.where(BacktestRun.source["kind" if key == "source" else key].as_string() == value)
|
||||
if args.alpha_type:
|
||||
query = query.where(BacktestItem.alpha_type == args.alpha_type)
|
||||
if args.status:
|
||||
query = query.where(BacktestRun.status == args.status)
|
||||
if args.created_from:
|
||||
@@ -89,7 +91,7 @@ class EvidenceQueries:
|
||||
query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay)
|
||||
if args.q:
|
||||
escaped = args.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query = query.where(BacktestItem.expression.ilike(f"%{escaped}%", escape="\\"))
|
||||
query = query.where(or_(*(getattr(BacktestItem, key).ilike(f"%{escaped}%", escape="\\") for key in ("expression", "selection", "combo"))))
|
||||
matches = {}
|
||||
if args.candidates:
|
||||
for c in args.candidates:
|
||||
@@ -120,6 +122,9 @@ class EvidenceQueries:
|
||||
if not item:
|
||||
raise ResearchError("NOT_FOUND", "候选不存在")
|
||||
result = await self.db.get(BacktestResult, item.id)
|
||||
if args.kind == "components":
|
||||
from ..superalpha.evidence import actual_components
|
||||
return await actual_components(self.db, item.id, args.limit, args.offset)
|
||||
if args.kind == "snapshot":
|
||||
# Top-level entries retain complete nested values; no hidden string/list truncation.
|
||||
entries = [{"key": k, "value": v} for k, v in sanitize(result.snapshot).items()] if result else []
|
||||
|
||||
@@ -25,6 +25,8 @@ from ..research.workspace_contracts import FieldAvailabilityInput
|
||||
from ..schemas import JobInput
|
||||
from ..submission import CheckInput, correlation_allows_check, create_check_job, local_alpha, source
|
||||
from ..submission import fingerprint as submission_fingerprint
|
||||
from ..superalpha.access import SuperResearchAccess
|
||||
from ..worldquant import WqError
|
||||
from .contracts import DirectCandidate, History
|
||||
from .queries import EvidenceQueries, page
|
||||
|
||||
@@ -36,7 +38,7 @@ class ResearchError(Exception):
|
||||
"retry_after": retry_after, "affected_items": affected_items or []}
|
||||
|
||||
|
||||
class ResearchAccess:
|
||||
class ResearchAccess(SuperResearchAccess):
|
||||
def __init__(self, db, principal, client, public_origin):
|
||||
self.db, self.principal, self.client = db, principal, client
|
||||
self.public_origin = public_origin.rstrip("/")
|
||||
@@ -48,15 +50,44 @@ class ResearchAccess:
|
||||
def run_url(self, run_id):
|
||||
return f"{self.public_origin}/#backtests?run_id={run_id}"
|
||||
|
||||
async def pyramid_distribution(self, args):
|
||||
"""Read the supplied date's full quarter using the three-Alpha completion rule."""
|
||||
from .pyramids import distribution, quarter_period
|
||||
|
||||
account = await self.db.get(Account, self.principal.account_id)
|
||||
if not account or account.wq_user_id != self.principal.wq_user_id:
|
||||
raise ResearchError("ACCOUNT_MISMATCH", "平台账户绑定已变化")
|
||||
period = quarter_period(args.current_date)
|
||||
try:
|
||||
raw = await self.client.get_pyramid_alphas(period["start_date"], period["end_date"])
|
||||
groups = distribution(raw, args.region, args.delay)
|
||||
except WqError as exc:
|
||||
raise ResearchError(exc.code.upper(), str(exc), retryable=True) from None
|
||||
except ValueError as exc:
|
||||
raise ResearchError("INVALID_PLATFORM_DATA", str(exc)) from None
|
||||
return {"region": args.region, "delay": args.delay, "threshold": 3,
|
||||
"current_date": args.current_date.isoformat(), "period": period,
|
||||
"source": "worldquant_platform", **groups}
|
||||
|
||||
async def capabilities(self, args):
|
||||
return {**await self.backtests.capabilities(), "max_candidates": 100,
|
||||
"settings_schema": DirectCandidate.model_json_schema(),
|
||||
"superalpha": {"plan_with": "save_superalpha_plan", "build_with": "build_superalpha_candidates",
|
||||
"preview_with": "preview_superalpha_selection", "read_selection_with": "get_superalpha_selection",
|
||||
"job_with": "get_refresh_job", "backtest_with": "submit_backtests", "platform_batch_size": 1,
|
||||
"component_evidence": "预览与实际组件分别记录,未知不能认定为同池"},
|
||||
"confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
|
||||
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
|
||||
"metadata_only": True, "actual_platform_allowance": None,
|
||||
"templates": {
|
||||
"create_with": "create_research_template", "required_scope": "research:write",
|
||||
"authored_by": "caller", "max_source_items": 20,
|
||||
"version_with": "create_research_template_version",
|
||||
"read_with": "get_research_template", "search_with": "search_research_templates",
|
||||
"authored_by": "caller", "source_items_required": False, "max_source_items": 20,
|
||||
"expand_with": "expand_research_template", "candidates_with": "get_template_candidates",
|
||||
"backtest_with": "start_template_backtest", "execution_scope": "backtests:execute",
|
||||
"max_candidates": 10000, "page_size_max": 100,
|
||||
"validation": "syntax_and_preparation_settings_combination",
|
||||
"source_items_with": "get_backtest_results", "starts_backtests": False,
|
||||
"web_url": f"{self.public_origin}/#templates",
|
||||
},
|
||||
@@ -139,15 +170,23 @@ class ResearchAccess:
|
||||
async def metadata(self, args):
|
||||
q = args.query
|
||||
metadata = ResearchMetadata(self.db)
|
||||
if q.kind == "superalpha":
|
||||
from ..superalpha.metadata import metadata as super_metadata
|
||||
return await super_metadata(self.db)
|
||||
if q.kind == "scopes":
|
||||
return {"source": "worldquant_platform", **await platform_options(self.client)}
|
||||
if q.kind == "operators":
|
||||
data = await metadata.operators(q.q, q.category, limit=q.limit, offset=q.offset)
|
||||
data = await metadata.operators(q.q, q.category, limit=q.limit, offset=q.offset, stage=q.stage)
|
||||
return {**data, "status": "available" if data["fetched_at"] else "not_cached",
|
||||
"has_more": q.offset + len(data["items"]) < data["total"]}
|
||||
if q.kind == "settings":
|
||||
data = await metadata.get("settings")
|
||||
items = data["content"].get("items", [])
|
||||
special = {"selectionHandling", "selectionLimit", "componentActivation"}
|
||||
if q.alpha_type == "REGULAR":
|
||||
items = [{**r, "fields": {k: v for k, v in r.get("fields", {}).items() if k not in special}} for r in items]
|
||||
else:
|
||||
items = [{**r, "super_settings_completeness": "cached" if special <= r.get("fields", {}).keys() else "unknown"} for r in items]
|
||||
return {"status": "available" if data["fetched_at"] else "not_cached",
|
||||
"fetched_at": data["fetched_at"], **page(items[q.offset:q.offset+q.limit], len(items), q.limit, q.offset)}
|
||||
data = await metadata.get(availability_key(q.field_id, q.scope))
|
||||
@@ -181,14 +220,16 @@ class ResearchAccess:
|
||||
|
||||
async def refresh_job(self, args):
|
||||
job = await self.db.get(Job, args.job_id)
|
||||
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "self_correlation", "submission_check"}:
|
||||
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "self_correlation", "submission_check", "super_selection_preview"}:
|
||||
raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
|
||||
result = await self.business.get_job_status(args.job_id)
|
||||
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
|
||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
errors = list(await self.db.scalars(query.order_by(JobItem.alpha_id).limit(args.limit).offset(args.offset)))
|
||||
result.pop("errors", None)
|
||||
return {**result, "job_id": job.id, "artifact_reference": job.payload,
|
||||
artifact = ({"job_id": job.id, "snapshot_id": job.checkpoint.get("snapshot_id"), "read_with": "get_superalpha_selection"}
|
||||
if job.kind == "super_selection_preview" else job.payload)
|
||||
return {**result, "job_id": job.id, "artifact_reference": artifact,
|
||||
"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):
|
||||
@@ -250,6 +291,76 @@ class ResearchAccess:
|
||||
return await self.remember("create_research_template", args, digest, result,
|
||||
business_id=result["template_id"])
|
||||
|
||||
async def template(self, args):
|
||||
"""Read the exact saved template revision without executing research."""
|
||||
from ..research.assets import Assets
|
||||
|
||||
return await Assets(self.db).get(args.template_id, args.version, "template")
|
||||
|
||||
async def templates(self, args):
|
||||
"""Search the same template library used by the browser."""
|
||||
from ..research.assets import Assets
|
||||
|
||||
return await Assets(self.db).list("template", **args.model_dump())
|
||||
|
||||
async def create_template_version(self, args):
|
||||
"""Append an idempotent, optimistic revision with refreshed source evidence."""
|
||||
from .templates import create_template
|
||||
|
||||
operation = "create_research_template_version"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
result = await create_template(self.db, args, self.principal, asset_id=args.template_id)
|
||||
result["web_url"] = f"{self.public_origin}/#templates"
|
||||
return await self.remember(operation, args, digest, result, business_id=args.template_id)
|
||||
|
||||
async def expand_template(self, args):
|
||||
"""Idempotently freeze a version and preparations; never start execution."""
|
||||
from ..research.experiments import Experiments
|
||||
from ..research.workspace_contracts import Expansion
|
||||
|
||||
operation = "expand_research_template"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
service = Experiments(self.db)
|
||||
asset = await service.assets.get(args.template_id, args.version, "template")
|
||||
experiment = await service.create(Expansion(
|
||||
asset_id=args.template_id, version=args.version, preparation_refs=args.preparation_refs,
|
||||
settings=args.settings, mode=args.mode, limit=args.limit, seed=args.seed,
|
||||
hypothesis=asset["content"].get("description", "").strip() or f"使用模板:{asset['name']}",
|
||||
), extra_evidence={"method": "template", "mcp_token_id": self.principal.token_id,
|
||||
"admin_id": self.principal.admin_id})
|
||||
result = await service.template_candidates(experiment["id"])
|
||||
result.update({"read_with": "get_template_candidates", "backtest_with": "start_template_backtest",
|
||||
"starts_backtests": False})
|
||||
return await self.remember(operation, args, digest, result, business_id=experiment["id"])
|
||||
|
||||
async def template_candidates(self, args):
|
||||
from ..research.experiments import Experiments
|
||||
|
||||
return await Experiments(self.db).template_candidates(args.experiment_id, args.limit, args.offset)
|
||||
|
||||
async def start_template_backtest(self, args):
|
||||
"""Execute only stored candidate IDs; authorization comes from the caller's execute scope."""
|
||||
from ..research.experiments import Experiments
|
||||
from ..research.workspace_contracts import TemplateBacktest
|
||||
|
||||
operation = "start_template_backtest"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
provenance = {"mcp_token_id": self.principal.token_id, "admin_id": self.principal.admin_id}
|
||||
result = await Experiments(self.db).start_template_backtest(
|
||||
args.experiment_id,
|
||||
TemplateBacktest(candidate_ids=args.candidate_ids, idempotency_key="mcp-template-" + str(uuid4())),
|
||||
backtests=Backtests(self.db, provenance),
|
||||
)
|
||||
result = {**result, "input_digest": digest, "web_url": self.run_url(result["backtest_run_id"])}
|
||||
return await self.remember(operation, args, digest, result,
|
||||
business_id=result["backtest_run_id"], wake="backtests")
|
||||
|
||||
async def previous(self, operation, args):
|
||||
# PostgreSQL row lock is shared with HTTP start and catalog/job creation.
|
||||
account = await self.db.scalar(select(Account).where(Account.id == self.principal.account_id).with_for_update())
|
||||
@@ -286,7 +397,11 @@ class ResearchAccess:
|
||||
invalid.append(c.client_item_id)
|
||||
if invalid:
|
||||
raise ResearchError("UNSUPPORTED_SETTINGS", "已缓存平台设置不支持这些组合;可显式刷新后重试", affected_items=invalid)
|
||||
return {"settings_validation": "cached", "fetched_at": snapshot["fetched_at"], "field_validation": "unknown"}
|
||||
result = {"settings_validation": "cached", "fetched_at": snapshot["fetched_at"], "field_validation": "unknown"}
|
||||
if any(c.alpha_type == "SUPER" for c in candidates):
|
||||
from ..superalpha.settings import validate_settings
|
||||
result["super_settings_validation"] = await validate_settings(self.db, [c.settings for c in candidates if c.alpha_type == "SUPER"])
|
||||
return result
|
||||
|
||||
async def submit(self, args):
|
||||
previous, digest = await self.previous("submit_backtests", args)
|
||||
|
||||
@@ -10,23 +10,27 @@ from ..research.workspace_contracts import AssetWrite
|
||||
from .queries import item_summary
|
||||
|
||||
|
||||
async def create_template(db, args, principal):
|
||||
"""Save a new asset inside the caller's account-locked, idempotent transaction.
|
||||
async def create_template(db, args, principal, *, asset_id=None):
|
||||
"""Save a new asset or revision inside the caller's account-locked, idempotent transaction.
|
||||
|
||||
Args contain the external model's TemplateSpec and local source item IDs.
|
||||
Return the versioned asset and theoretical combination count. Raise
|
||||
ResearchError for name conflicts or missing/incomplete research evidence.
|
||||
Stored evidence proves provenance, not profitability or platform eligibility;
|
||||
schema validation does not validate every expanded FASTEXPR combination.
|
||||
Optional asset_id selects a version update guarded by args.expected_version;
|
||||
a stale version raises HTTP 409 and cannot overwrite a historical revision.
|
||||
"""
|
||||
from .service import ResearchError
|
||||
|
||||
existing = await db.scalar(select(ResearchAsset).where(
|
||||
ResearchAsset.kind == "template", ResearchAsset.name == args.template.name,
|
||||
ResearchAsset.id != asset_id if asset_id else True,
|
||||
).order_by(ResearchAsset.id).limit(1))
|
||||
if existing:
|
||||
raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板",
|
||||
affected_items=[{"template_id": existing.id, "version": existing.version}])
|
||||
previous = await Assets(db).get(asset_id, expected_kind="template") if asset_id else None
|
||||
rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin(
|
||||
BacktestResult, BacktestResult.item_id == BacktestItem.id,
|
||||
).where(BacktestItem.id.in_(args.source_item_ids)))).all()
|
||||
@@ -48,13 +52,17 @@ async def create_template(db, args, principal):
|
||||
"admin_id": principal.admin_id,
|
||||
"source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids],
|
||||
}
|
||||
if previous:
|
||||
provenance["parent_template"] = {"id": asset_id, "version": args.expected_version}
|
||||
asset = await Assets(db).save(AssetWrite(
|
||||
kind="template", content=args.template.model_dump(mode="json"),
|
||||
), provenance=provenance)
|
||||
version=args.expected_version if asset_id else None,
|
||||
), asset_id=asset_id, provenance=provenance)
|
||||
return {
|
||||
**asset, "template_id": asset["id"],
|
||||
"combination_count": str(math.prod(len(v.values) for v in args.template.variables.values())),
|
||||
"validation": {"structure": "valid", "source_evidence": "recorded",
|
||||
"combination_count": (str(math.prod(len(v.values) for v in args.template.variables.values()))
|
||||
if all(v.values for v in args.template.variables.values()) else None),
|
||||
"validation": {"structure": "valid", "source_evidence": "recorded" if args.source_item_ids else "not_provided",
|
||||
"expanded_candidates": "not_validated", "platform_semantics": "unknown"},
|
||||
"next_step": "在模板工坊选择固定输入及模拟设置,展开并核验候选,再确认批量回测。",
|
||||
"next_step": "选择数据准备和回测参数,用 expand_research_template 生成候选集合;核对候选后按已获授权范围调用 start_template_backtest。也可在模板工坊完成。",
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
|
||||
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
|
||||
Submission = Literal["UNSUBMITTED", "SUBMITTED"]
|
||||
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"]
|
||||
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "PPAC_CANDIDATE", "FAIL_1", "FAIL_2"]
|
||||
SortField = Literal[
|
||||
"id",
|
||||
"name",
|
||||
@@ -69,10 +69,12 @@ class PreferencesInput(Contract):
|
||||
|
||||
|
||||
class AlphaFilters(Contract):
|
||||
management_scope: Literal["super", "non_super"] | None = None
|
||||
local_correlation_status: Literal["not_cached", "stale", "low", "high", "partial", "insufficient_data"] | None = None
|
||||
local_correlation_min: float | None = Field(default=None, ge=-1, le=1)
|
||||
local_correlation_max: float | None = Field(default=None, ge=-1, le=1)
|
||||
submission_blocked: bool | None = None
|
||||
ppac_candidate: bool | None = None
|
||||
submission: Submission | None = None
|
||||
source: str | None = Field(default=None, max_length=100)
|
||||
source_reference: str | None = Field(default=None, max_length=200)
|
||||
@@ -237,6 +239,9 @@ class AlphaSummary(BaseModel):
|
||||
id: str
|
||||
name: str | None
|
||||
expression_preview: str
|
||||
selection_preview: str = ""
|
||||
combo_preview: str = ""
|
||||
component_count: int | None = None
|
||||
alpha_type: str | None
|
||||
language: str | None
|
||||
stage: str | None
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Super Alpha construction and immutable component evidence over shared execution."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""MCP adapter using the same SUPER operations and records as the web editor."""
|
||||
|
||||
from ..research.assets import Assets
|
||||
from .evidence import read_selection
|
||||
from .service import SuperResearch
|
||||
|
||||
|
||||
class SuperResearchAccess:
|
||||
async def super_plans(self, args):
|
||||
return await Assets(self.db).list("superalpha_plan", args.q, args.limit, args.offset)
|
||||
|
||||
async def super_plan(self, args):
|
||||
if args.experiment_id:
|
||||
return await SuperResearch(self.db).experiment(args.experiment_id, args.limit, args.offset)
|
||||
return await Assets(self.db).get(args.plan_id, args.version, "superalpha_plan")
|
||||
|
||||
async def save_super_plan(self, args):
|
||||
result = await SuperResearch(self.db).save(args)
|
||||
return {**result, "web_url": f"{self.public_origin}/#superalpha-research?plan_id={result['id']}"}
|
||||
|
||||
async def preview_super_selection(self, args):
|
||||
result = await SuperResearch(self.db).selection_job(args)
|
||||
self.wake = "jobs"
|
||||
return result
|
||||
|
||||
async def super_selection(self, args):
|
||||
return await read_selection(self.db, args)
|
||||
|
||||
async def build_super_candidates(self, args):
|
||||
result = await SuperResearch(self.db).build(args)
|
||||
return {**result, "web_url": f"{self.public_origin}/#superalpha-research?experiment_id={result['id']}",
|
||||
"submit_with": "submit_backtests", "starts_backtests": False,
|
||||
"submit_source": {k: result["source"][k] for k in ("research_id", "superalpha_plan_id", "superalpha_plan_version", "selection_snapshot_ids", "reference", "hypothesis")},
|
||||
"paging": "完整候选可用 get_superalpha_plan 的 experiment_id 读取"}
|
||||
|
||||
async def super_alphas(self, args):
|
||||
args.filters.management_scope = "super"
|
||||
args.filters.alpha_type = "SUPER"
|
||||
return await self.business.search_alphas(args.filters)
|
||||
|
||||
async def super_alpha(self, args):
|
||||
result = await SuperResearch(self.db).alpha(args.alpha_id)
|
||||
return {**result, "web_url": f"{self.public_origin}/#superalphas?alpha_id={args.alpha_id}"}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Bounded SUPER authoring inputs, independent from regular data-field preparation."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from ..backtests.contracts import SuperSimulationSettings
|
||||
from ..research.expressions import IDENTIFIER, PLACEHOLDER
|
||||
from ..research.workspace_contracts import Variable
|
||||
from ..schemas import AlphaFilters, Contract
|
||||
|
||||
|
||||
class PlanSpec(Contract):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
hypothesis: str = Field(min_length=1, max_length=2000)
|
||||
selection: str = Field(min_length=1, max_length=20000)
|
||||
combo: str = Field(min_length=1, max_length=20000)
|
||||
variables: dict[str, Variable] = Field(default_factory=dict, max_length=50)
|
||||
settings: SuperSimulationSettings
|
||||
setting_variants: dict[str, list[str | int | float | bool]] = Field(default_factory=dict, max_length=20)
|
||||
include_baseline: bool = False
|
||||
reference: str = Field(default="", max_length=200)
|
||||
parent_plan_id: str | None = Field(default=None, max_length=36)
|
||||
parent_plan_version: int | None = Field(default=None, ge=1)
|
||||
parent_alpha_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
parent_experiment_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
@field_validator("name", "hypothesis", "selection", "combo")
|
||||
@classmethod
|
||||
def text(cls, value):
|
||||
if not value.strip():
|
||||
raise ValueError("内容不能为空")
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bindings(self):
|
||||
text = self.selection + "\n" + self.combo
|
||||
if set(PLACEHOLDER.findall(text)) != set(self.variables):
|
||||
raise ValueError("Selection/Combo 占位符必须与变量逐一对应")
|
||||
if any(not IDENTIFIER.fullmatch(k) or v.kind == "field" for k, v in self.variables.items()):
|
||||
raise ValueError("SUPER 变量须使用合法名称,不能使用 REGULAR 数据字段绑定")
|
||||
if "{" in PLACEHOLDER.sub("", text) or "}" in PLACEHOLDER.sub("", text):
|
||||
raise ValueError("占位符格式错误")
|
||||
for key, values in self.setting_variants.items():
|
||||
if key not in SuperSimulationSettings.model_fields or not 1 <= len(values) <= 100:
|
||||
raise ValueError("设置变量必须为已支持设置,每项 1–100 个候选值")
|
||||
for value in values:
|
||||
SuperSimulationSettings.model_validate({**self.settings.model_dump(), key: value})
|
||||
if bool(self.parent_plan_id) != bool(self.parent_plan_version):
|
||||
raise ValueError("父方案必须同时指定 ID 和版本")
|
||||
return self
|
||||
|
||||
|
||||
class PlanSearch(Contract):
|
||||
q: str = Field(default="", max_length=200)
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class PlanReference(PlanSearch):
|
||||
plan_id: str | None = Field(default=None, min_length=1, max_length=36)
|
||||
experiment_id: str | None = Field(default=None, min_length=1, max_length=36)
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_reference(self):
|
||||
if bool(self.plan_id) == bool(self.experiment_id) or (self.version and not self.plan_id):
|
||||
raise ValueError("提供 plan_id 或 experiment_id 之一;version 仅用于方案")
|
||||
return self
|
||||
|
||||
|
||||
class PlanSave(Contract):
|
||||
plan: PlanSpec
|
||||
plan_id: str | None = Field(default=None, max_length=36)
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
idempotency_key: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reference(self):
|
||||
if bool(self.plan_id) != bool(self.version):
|
||||
raise ValueError("更新须同时提供方案 ID 与当前版本")
|
||||
return self
|
||||
|
||||
|
||||
class SelectionPreview(Contract):
|
||||
plan_id: str | None = Field(default=None, max_length=36)
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
selection: str = Field(min_length=1, max_length=20000)
|
||||
settings: SuperSimulationSettings
|
||||
|
||||
@field_validator("selection")
|
||||
@classmethod
|
||||
def concrete(cls, value):
|
||||
if not value.strip() or "{" in value or "}" in value:
|
||||
raise ValueError("预览须提供展开后的非空 Selection")
|
||||
return value.strip()
|
||||
|
||||
def platform_query(self):
|
||||
return {"selection": self.selection, **self.settings.model_dump(include={
|
||||
"instrumentType", "region", "delay", "selectionLimit", "selectionHandling"})}
|
||||
|
||||
|
||||
class SelectionReference(PlanSearch):
|
||||
snapshot_id: str | None = Field(default=None, max_length=36)
|
||||
job_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one(self):
|
||||
if bool(self.snapshot_id) == bool(self.job_id):
|
||||
raise ValueError("提供 snapshot_id 或 job_id 之一")
|
||||
return self
|
||||
|
||||
|
||||
class BuildCandidates(Contract):
|
||||
plan: PlanSpec | None = None
|
||||
plan_id: str | None = Field(default=None, max_length=36)
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
mode: Literal["all", "random"] = "all"
|
||||
limit: int = Field(default=100, ge=1, le=10000)
|
||||
seed: int = Field(default=0, ge=0, le=2147483647)
|
||||
selection_snapshot_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||
idempotency_key: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one(self):
|
||||
if bool(self.plan) == bool(self.plan_id) or bool(self.plan_id) != bool(self.version):
|
||||
raise ValueError("提供内联方案或方案 ID/版本之一")
|
||||
return self
|
||||
|
||||
|
||||
class SuperAlphaSearch(Contract):
|
||||
filters: AlphaFilters = Field(default_factory=AlphaFilters)
|
||||
|
||||
|
||||
class AlphaReference(Contract):
|
||||
alpha_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,100}$")
|
||||
|
||||
|
||||
class ExperimentPreview(Contract):
|
||||
candidate_ids: list[str] = Field(min_length=1, max_length=10000)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Parse only explicit component evidence; never infer actual members from a preview."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..alphas import sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..models import SuperSelectionSnapshot
|
||||
from ..research.serialization import encode_snapshot
|
||||
|
||||
|
||||
def parse_components(raw):
|
||||
"""Return normalized rows and completeness; count/duplicate/next ambiguity stays unknown."""
|
||||
warnings = []
|
||||
if isinstance(raw, dict):
|
||||
supplied = raw.get("warnings", [])
|
||||
warnings.extend(supplied if isinstance(supplied, list) else [supplied])
|
||||
rows = raw.get("results", raw.get("alphas", raw.get("components")))
|
||||
total = raw.get("count", raw.get("total"))
|
||||
complete_hint = raw.get("complete") is True
|
||||
next_page = raw.get("next")
|
||||
else:
|
||||
rows, total, complete_hint, next_page = raw, None, False, None
|
||||
invalid_total = total is not None and (type(total) is not int or total < 0)
|
||||
total = total if type(total) is int and total >= 0 else None
|
||||
valid_shape = isinstance(rows, list)
|
||||
items, seen, malformed = [], set(), False
|
||||
for row in rows if valid_shape else []:
|
||||
entry = {"id": row} if isinstance(row, str) else row
|
||||
if not isinstance(entry, dict):
|
||||
malformed = True
|
||||
continue
|
||||
alpha_id = entry.get("id", entry.get("alpha", entry.get("alphaId")))
|
||||
if not isinstance(alpha_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", alpha_id) or alpha_id in seen:
|
||||
malformed = True
|
||||
continue
|
||||
seen.add(alpha_id)
|
||||
items.append({**sanitize(entry), "id": alpha_id})
|
||||
complete = valid_shape and not malformed and not invalid_total and not next_page and (
|
||||
(total is not None and total == len(items)) or (total is None and complete_hint))
|
||||
if not complete:
|
||||
warnings.append("组件列表未核实完整性;不生成完整组件指纹,不用于同池结论")
|
||||
return {"components": items, "total": total, "complete": complete,
|
||||
"component_hash": fingerprint({"alpha_ids": sorted(seen)}) if complete else None,
|
||||
"warnings": sanitize(warnings)}
|
||||
|
||||
|
||||
def snapshot_output(row, limit=25, offset=0, q=""):
|
||||
items = [item for item in row.components if not q or q.lower() in str(item).lower()]
|
||||
return encode_snapshot({"snapshot_id": row.id, "job_id": row.job_id, "item_id": row.item_id,
|
||||
"source": row.source, "request": row.request, "request_hash": row.request_hash,
|
||||
"component_hash": row.component_hash, "complete": row.complete, "reported_total": row.total,
|
||||
"observed_at": row.observed_at, "warnings": row.warnings,
|
||||
"status": "available" if row.complete else "unknown", "total": len(items),
|
||||
"limit": limit, "offset": offset, "has_more": offset + limit < len(items),
|
||||
"items": items[offset:offset + limit]})
|
||||
|
||||
|
||||
async def read_selection(db, args):
|
||||
query = select(SuperSelectionSnapshot)
|
||||
query = query.where(SuperSelectionSnapshot.id == args.snapshot_id) if args.snapshot_id else query.where(
|
||||
SuperSelectionSnapshot.job_id == args.job_id)
|
||||
row = await db.scalar(query)
|
||||
if not row:
|
||||
if args.job_id:
|
||||
from ..models import Job
|
||||
job = await db.get(Job, args.job_id)
|
||||
if not job or job.kind != "super_selection_preview":
|
||||
raise HTTPException(404, "组件预览任务不存在")
|
||||
return {"status": job.status, "snapshot_id": None, "job_id": job.id, "items": [],
|
||||
"total": 0, "complete": False, "error": job.error, "observed_at": None}
|
||||
raise HTTPException(404, "组件快照不存在")
|
||||
return snapshot_output(row, args.limit, args.offset, args.q)
|
||||
|
||||
|
||||
async def save_actual_components(db, item, detail, observed_at):
|
||||
raw = detail.get("components", detail.get("selectedAlphas"))
|
||||
if raw is None and isinstance(detail.get("selection"), dict):
|
||||
selection = detail["selection"]
|
||||
if isinstance(selection.get("alphas"), list):
|
||||
raw = {"alphas": selection["alphas"], "count": selection.get("count")}
|
||||
request = {"type": "SUPER", "selection": item.selection, "combo": item.combo, "settings": item.settings}
|
||||
parsed = parse_components(raw)
|
||||
db.add(SuperSelectionSnapshot(id=str(uuid4()), item_id=item.id, source="actual", request=request,
|
||||
request_hash=fingerprint(request), raw=sanitize(raw) if isinstance(raw, (dict, list)) else {},
|
||||
observed_at=datetime.fromisoformat(observed_at), **parsed))
|
||||
|
||||
|
||||
async def actual_components(db, item_id, limit=25, offset=0):
|
||||
row = await db.scalar(select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.item_id == item_id))
|
||||
return snapshot_output(row, limit, offset) if row else {
|
||||
"status": "unknown", "complete": False, "source": "actual", "items": [], "total": 0,
|
||||
"component_hash": None, "observed_at": None, "warnings": ["平台实际组件尚未核实"]}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Selection previews run on the existing durable job runner, outside request transactions."""
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..alphas import sanitize
|
||||
from ..backtests.contracts import fingerprint
|
||||
from ..models import Job, SuperSelectionSnapshot, now
|
||||
from .contracts import SelectionPreview
|
||||
from .evidence import parse_components
|
||||
|
||||
|
||||
async def run_selection(runner, job_id, payload):
|
||||
async with runner.sessions() as db:
|
||||
existing = await db.scalar(select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.job_id == job_id))
|
||||
if existing:
|
||||
return # Restart after snapshot commit must not replace the original observation.
|
||||
request = SelectionPreview.model_validate(payload)
|
||||
raw = await runner.client.run_super_selection(request.platform_query())
|
||||
parsed = parse_components(raw)
|
||||
async with runner.sessions.begin() as db:
|
||||
job = await db.get(Job, job_id)
|
||||
if job.cancel_requested:
|
||||
raise asyncio.CancelledError()
|
||||
snapshot = SuperSelectionSnapshot(id=str(uuid4()), job_id=job_id, source="preview", request=payload,
|
||||
request_hash=fingerprint(request.platform_query()), raw=sanitize(raw), **parsed)
|
||||
db.add(snapshot)
|
||||
job.processed, job.total, job.updated_at = 1, 1, now()
|
||||
job.checkpoint = {"snapshot_id": snapshot.id, "complete": parsed["complete"]}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Separate Alpha selection properties from stock data fields; availability remains evidence based."""
|
||||
|
||||
from ..backtests.contracts import SuperSimulationSettings
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
|
||||
|
||||
async def metadata(db):
|
||||
settings = await ResearchMetadata(db).get("settings")
|
||||
return {"settings_schema": SuperSimulationSettings.model_json_schema(), "settings_snapshot": settings,
|
||||
"selection_properties": [{"name": name, "description": description} for name, description in (
|
||||
("category", "用户设置的 Alpha 类别"), ("color", "用户设置的颜色"),
|
||||
("datasets", "组件使用的数据集集合,可配合 in()"), ("datafields", "组件使用的数据字段集合"),
|
||||
("datacategories", "组件使用的数据类别集合"), ("dataset_count", "不同数据集数量"),
|
||||
("datafield_count", "不同数据字段数量"), ("datacategory_count", "不同数据类别数量"),
|
||||
("decay", "组件的衰减设置"), ("favorite", "平台收藏状态"), ("name", "组件名称,按完整名称匹配"),
|
||||
("neutralization", "组件的中性化设置"), ("operator_count", "组件表达式算子数量"),
|
||||
("long_count", "IS 平均多头股票数量"), ("short_count", "IS 平均空头股票数量"),
|
||||
("tags", "组件的自定义标签集合"), ("truncation", "组件截断设置"),
|
||||
("turnover", "组件 IS 换手率"), ("universe", "组件股票池名称"),
|
||||
("self_correlation", "组件自相关属性"), ("prod_correlation", "组件生产相关性属性"),
|
||||
("os_start_date", "组件样本外起始日期,YYYY-MM-DD 字符串"),
|
||||
("classifications", "组件分类集合"), ("competitions", "组件关联比赛集合"))],
|
||||
"property_source": "BRAIN Selection Expression 文档快照(2025-10-16);属性列表非账户实时授权清单,具体可用性以平台响应为准",
|
||||
"combo_input": "alpha 表示选中的组件;Combo 返回每日每个组件的权重,常量 1 可作为等权基线",
|
||||
"selection_object": "平台可供选择的已提交 ACTIVE Alpha;本地列表不等同于平台完整组件池",
|
||||
"operator_query": {"kind": "operators", "stage": "SELECTION"},
|
||||
"validation": "结构校验与平台执行分开;缺少适用范围的算子保持未知"}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Authenticated SUPER authoring endpoints; construction never starts a simulation."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..models import SuperSelectionSnapshot
|
||||
from ..research.assets import Assets
|
||||
from ..security import require_auth
|
||||
from .contracts import BuildCandidates, ExperimentPreview, PlanSave, SelectionPreview, SelectionReference
|
||||
from .evidence import read_selection, snapshot_output
|
||||
from .metadata import metadata
|
||||
from .service import SuperResearch
|
||||
|
||||
router = APIRouter(prefix="/api/v1/superalpha", tags=["superalpha"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
async def get_metadata(request: Request):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await metadata(db)
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def plans(request: Request, q: str = "", limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await Assets(db).list("superalpha_plan", q, limit, offset)
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
async def save_plan(body: PlanSave, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await SuperResearch(db).save(body)
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}")
|
||||
async def plan(plan_id: str, request: Request, version: int | None = Query(None, ge=1)):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await Assets(db).get(plan_id, version, "superalpha_plan")
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}/versions")
|
||||
async def versions(plan_id: str, request: Request):
|
||||
async with request.app.state.sessions() as db:
|
||||
await Assets(db).get(plan_id, expected_kind="superalpha_plan")
|
||||
return await Assets(db).versions(plan_id)
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def archive(plan_id: str, request: Request, version: int = Query(..., ge=1)):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
await Assets(db).get(plan_id, expected_kind="superalpha_plan")
|
||||
return await Assets(db).archive(plan_id, version)
|
||||
|
||||
|
||||
@router.post("/selections", status_code=202)
|
||||
async def preview_selection(body: SelectionPreview, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
result = await SuperResearch(db).selection_job(body)
|
||||
request.app.state.runner.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/selections")
|
||||
async def selection(request: Request, snapshot_id: str | None = None, job_id: str | None = None,
|
||||
q: str = "", limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
try:
|
||||
args = SelectionReference(snapshot_id=snapshot_id, job_id=job_id, q=q, limit=limit, offset=offset)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(422, str(exc)) from None
|
||||
async with request.app.state.sessions() as db:
|
||||
return await read_selection(db, args)
|
||||
|
||||
|
||||
@router.post("/candidates", status_code=201)
|
||||
async def build(body: BuildCandidates, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await SuperResearch(db).build(body)
|
||||
|
||||
|
||||
@router.get("/experiments")
|
||||
async def experiments(request: Request, plan_id: str | None = None,
|
||||
limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await SuperResearch(db).experiments(plan_id, limit, offset)
|
||||
|
||||
|
||||
@router.get("/experiments/{experiment_id}")
|
||||
async def experiment(experiment_id: str, request: Request, limit: int = Query(100, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await SuperResearch(db).experiment(experiment_id, limit, offset)
|
||||
|
||||
|
||||
@router.post("/experiments/{experiment_id}/preview")
|
||||
async def preview(experiment_id: str, body: ExperimentPreview, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
return await SuperResearch(db).preview(experiment_id, body.candidate_ids)
|
||||
|
||||
|
||||
@router.get("/alphas/{alpha_id}")
|
||||
async def alpha(alpha_id: str, request: Request):
|
||||
async with request.app.state.sessions() as db:
|
||||
return await SuperResearch(db).alpha(alpha_id)
|
||||
|
||||
|
||||
@router.get("/selection-history")
|
||||
async def selection_history(request: Request, plan_id: str, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
query = select(SuperSelectionSnapshot).where(SuperSelectionSnapshot.source == "preview", SuperSelectionSnapshot.request["plan_id"].as_string() == plan_id)
|
||||
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
rows = await db.scalars(query.order_by(SuperSelectionSnapshot.observed_at.desc(), SuperSelectionSnapshot.id).limit(limit).offset(offset))
|
||||
return {"items": [snapshot_output(row, 0) for row in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/experiments/{experiment_id}/results")
|
||||
async def experiment_results(experiment_id: str, request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
from ..models import Pnl
|
||||
from ..research.serialization import encode_snapshot
|
||||
from ..research_access.contracts import History
|
||||
from ..research_access.queries import EvidenceQueries
|
||||
from .evidence import actual_components
|
||||
async with request.app.state.sessions() as db:
|
||||
await SuperResearch(db).experiment(experiment_id, 1)
|
||||
result = await EvidenceQueries(db).history(History(research_id=experiment_id, alpha_type="SUPER", limit=limit, offset=offset))
|
||||
for item in result["items"]:
|
||||
item["components"] = await actual_components(db, item["id"], 0)
|
||||
pnl = await db.get(Pnl, item["alpha_id"]) if item["alpha_id"] else None
|
||||
item["pnl_fetched_at"] = pnl.fetched_at if pnl else None
|
||||
return encode_snapshot(result)
|
||||
|
||||
|
||||
@router.get("/alphas/{alpha_id}/components")
|
||||
async def alpha_components(alpha_id: str, request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)):
|
||||
async with request.app.state.sessions() as db:
|
||||
return (await SuperResearch(db).alpha(alpha_id, limit, offset))["components"]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Versioned SUPER plans and deterministic candidate construction; never executes simulations."""
|
||||
|
||||
import math
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..alphas import sanitize
|
||||
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, Source, fingerprint
|
||||
from ..backtests.service import Backtests
|
||||
from ..models import Account, Alpha, Job, ResearchExperiment, ResearchRequest, SuperSelectionSnapshot, now
|
||||
from ..research.assets import Assets
|
||||
from ..research.expressions import PLACEHOLDER
|
||||
from ..research.serialization import encode_snapshot
|
||||
from ..research.workspace_contracts import AssetWrite
|
||||
from .contracts import PlanSpec, SelectionPreview
|
||||
from .evidence import actual_components, parse_components
|
||||
from .settings import validate_settings
|
||||
|
||||
|
||||
async def validate_source(db, source, candidates):
|
||||
"""Verify server-owned provenance references without making assets mandatory for direct execution."""
|
||||
if (source.get("superalpha_plan_id") or source.get("selection_snapshot_ids") or source.get("kind") == "superalpha") and any(c.get("alpha_type", "REGULAR") != "SUPER" for c in candidates):
|
||||
raise HTTPException(422, "Super Alpha 方案或组件来源只能关联 SUPER 候选")
|
||||
await validate_settings(db, [c["settings"] for c in candidates if c.get("alpha_type") == "SUPER"])
|
||||
experiment = None
|
||||
if bool(source.get("superalpha_plan_id")) != bool(source.get("superalpha_plan_version")):
|
||||
raise HTTPException(422, "方案引用须同时指定 ID 和版本")
|
||||
if source.get("superalpha_plan_id"):
|
||||
if not source.get("superalpha_plan_version"):
|
||||
raise HTTPException(422, "方案引用须指定版本")
|
||||
await Assets(db).get(source["superalpha_plan_id"], source["superalpha_plan_version"], "superalpha_plan")
|
||||
if source.get("research_id") and (source.get("kind") == "superalpha" or source.get("superalpha_plan_id") or any(c.get("alpha_type") == "SUPER" for c in candidates)):
|
||||
experiment = await db.get(ResearchExperiment, source["research_id"])
|
||||
if not experiment or experiment.kind != "superalpha":
|
||||
raise HTTPException(404, "SUPER 候选构造记录不存在")
|
||||
source["research_kind"] = "superalpha"
|
||||
expected = {c["client_item_id"]: fingerprint(Candidate.model_validate(c).platform_input()) for c in experiment.candidates}
|
||||
for value in candidates:
|
||||
c = Candidate.model_validate(value)
|
||||
if expected.get(c.client_item_id) != fingerprint(c.platform_input()):
|
||||
raise HTTPException(409, "候选与引用的固定构造记录不一致")
|
||||
ref = experiment.evidence.get("plan_reference", {})
|
||||
if source.get("superalpha_plan_id") and ref != {
|
||||
"id": source["superalpha_plan_id"], "version": source["superalpha_plan_version"]}:
|
||||
raise HTTPException(409, "方案版本与构造来源不一致")
|
||||
for snapshot_id in source.get("selection_snapshot_ids", []):
|
||||
row = await db.get(SuperSelectionSnapshot, snapshot_id)
|
||||
if not row or row.source != "preview":
|
||||
raise HTTPException(404, "Selection 预览快照不存在")
|
||||
query = SelectionPreview.model_validate(row.request).platform_query()
|
||||
if not any(Candidate.model_validate(c).alpha_type == "SUPER" and SelectionPreview(
|
||||
selection=c["selection"], settings=c["settings"]).platform_query() == query for c in (experiment.candidates if experiment else candidates)):
|
||||
raise HTTPException(409, "组件预览与候选 Selection/范围不匹配")
|
||||
|
||||
|
||||
class SuperResearch:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.assets = Assets(db)
|
||||
|
||||
async def previous(self, operation, args):
|
||||
account = await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||
if not account:
|
||||
raise HTTPException(409, "工作空间未初始化")
|
||||
digest = fingerprint(args.model_dump(mode="json", exclude={"idempotency_key"}))
|
||||
previous = await self.db.scalar(select(ResearchRequest).where(ResearchRequest.account_id == 1,
|
||||
ResearchRequest.operation == operation, ResearchRequest.idempotency_key == args.idempotency_key))
|
||||
if previous and previous.digest != digest:
|
||||
raise HTTPException(409, "幂等键已用于不同内容")
|
||||
return previous, digest
|
||||
|
||||
async def remember(self, operation, args, digest, result, business_id):
|
||||
result = encode_snapshot(result)
|
||||
result["_meta"] = {"schema_version": 1, "observed_at": now().isoformat(), "source": "system"}
|
||||
self.db.add(ResearchRequest(id=str(uuid4()), account_id=1, operation=operation,
|
||||
idempotency_key=args.idempotency_key, digest=digest, business_id=business_id, response=result))
|
||||
await self.db.flush()
|
||||
return result
|
||||
|
||||
async def provenance(self, plan):
|
||||
result = {"reference": plan.reference}
|
||||
if plan.parent_plan_id:
|
||||
parent = await self.assets.get(plan.parent_plan_id, plan.parent_plan_version, "superalpha_plan")
|
||||
result["parent_plan"] = {k: parent[k] for k in ("id", "version", "name")}
|
||||
if plan.parent_alpha_id:
|
||||
alpha = await self.db.get(Alpha, plan.parent_alpha_id)
|
||||
if not alpha or alpha.alpha_type != "SUPER":
|
||||
raise HTTPException(404, "父 SUPER Alpha 尚未导入")
|
||||
result["parent_alpha"] = {"id": alpha.id, "snapshot": sanitize(alpha.raw), "observed_at": alpha.synced_at}
|
||||
if plan.parent_experiment_id:
|
||||
parent = await self.experiment(plan.parent_experiment_id)
|
||||
result["parent_experiment"] = {"id": parent["id"], "created_at": parent["created_at"]}
|
||||
return encode_snapshot(result)
|
||||
|
||||
async def save(self, args):
|
||||
previous, digest = await self.previous("save_superalpha_plan", args)
|
||||
if previous:
|
||||
return previous.response
|
||||
await validate_settings(self.db, [args.plan.settings])
|
||||
result = await self.assets.save(AssetWrite(kind="superalpha_plan", content=args.plan.model_dump(mode="json"),
|
||||
version=args.version), args.plan_id, await self.provenance(args.plan))
|
||||
return await self.remember("save_superalpha_plan", args, digest, result, result["id"])
|
||||
|
||||
async def build(self, args):
|
||||
previous, digest = await self.previous("build_superalpha_candidates", args)
|
||||
if previous:
|
||||
return previous.response
|
||||
asset = await self.assets.get(args.plan_id, args.version, "superalpha_plan") if args.plan_id else None
|
||||
plan = PlanSpec.model_validate(asset["content"]) if asset else args.plan
|
||||
provenance = await self.provenance(plan)
|
||||
names = list(plan.variables)
|
||||
settings_names = list(plan.setting_variants)
|
||||
axes = [plan.variables[k].values for k in names] + [plan.setting_variants[k] for k in settings_names]
|
||||
count = math.prod(len(a) for a in axes)
|
||||
if count > 10**12 or (args.mode == "all" and count > args.limit):
|
||||
raise HTTPException(422, f"理论组合数 {count} 超出展开上限;缩小参数或采用随机采样")
|
||||
indices = range(count) if args.mode == "all" else sorted(random.Random(args.seed).sample(range(count), min(count, args.limit)))
|
||||
candidates, annotations, seen = [], {}, {}
|
||||
for index in indices:
|
||||
remaining, values = index, []
|
||||
for axis in reversed(axes):
|
||||
remaining, position = divmod(remaining, len(axis))
|
||||
values.insert(0, axis[position])
|
||||
bindings = dict(zip(names, values[:len(names)]))
|
||||
def substitute(text):
|
||||
return PLACEHOLDER.sub(lambda m: str(bindings[m.group(1)]), text)
|
||||
selection, combo = substitute(plan.selection), substitute(plan.combo)
|
||||
settings = {**plan.settings.model_dump(), **dict(zip(settings_names, values[len(names):]))}
|
||||
variants = [(combo, combo == "1")] + ([("1", True)] if plan.include_baseline and combo != "1" else [])
|
||||
for combo_value, baseline in variants:
|
||||
candidate_id = f"super-{index + 1}{'-baseline' if baseline else ''}"
|
||||
c = Candidate(client_item_id=candidate_id, alpha_type="SUPER", selection=selection,
|
||||
combo=combo_value, settings=settings)
|
||||
h = fingerprint(c.platform_input())
|
||||
annotations[candidate_id] = {"baseline": baseline, "parameters": bindings,
|
||||
"duplicate_of": seen.get(h), "request_hash": h}
|
||||
seen.setdefault(h, candidate_id)
|
||||
candidates.append(c.model_dump(mode="json"))
|
||||
if len(candidates) > 10000:
|
||||
raise HTTPException(422, "包含基线后超过 10000 项,请缩小候选数")
|
||||
plan_reference = {"id": asset["id"], "version": asset["version"]} if asset else {}
|
||||
source = Source(kind="superalpha", research_kind="superalpha", reference=plan.reference, hypothesis=plan.hypothesis,
|
||||
superalpha_plan_id=args.plan_id, superalpha_plan_version=args.version,
|
||||
selection_snapshot_ids=args.selection_snapshot_ids).model_dump(mode="json")
|
||||
await validate_source(self.db, source, candidates)
|
||||
experiment = ResearchExperiment(id=str(uuid4()), name=plan.name, kind="superalpha", hypothesis=plan.hypothesis,
|
||||
inputs=[], parents=[], candidates=candidates, evidence={"plan": plan.model_dump(mode="json"),
|
||||
"plan_reference": plan_reference, "provenance": provenance, "source": source,
|
||||
"selection_snapshot_ids": args.selection_snapshot_ids, "annotations": annotations,
|
||||
"combination_count": str(count), "mode": args.mode, "seed": args.seed})
|
||||
self.db.add(experiment)
|
||||
await self.db.flush()
|
||||
result = await self.experiment(experiment.id)
|
||||
return await self.remember("build_superalpha_candidates", args, digest, result, experiment.id)
|
||||
|
||||
async def experiment(self, experiment_id, limit=100, offset=0):
|
||||
row = await self.db.get(ResearchExperiment, experiment_id)
|
||||
if not row or row.kind != "superalpha":
|
||||
raise HTTPException(404, "SUPER 研究记录不存在")
|
||||
source = {**row.evidence["source"], "research_id": row.id}
|
||||
visible = row.candidates[offset:offset + limit]
|
||||
evidence = {**row.evidence, "annotations": {c["client_item_id"]: row.evidence["annotations"].get(c["client_item_id"], {}) for c in visible}}
|
||||
return encode_snapshot({"id": row.id, "name": row.name, "kind": row.kind, "hypothesis": row.hypothesis,
|
||||
"created_at": row.created_at, "evidence": evidence, "source": source,
|
||||
"candidates": row.candidates[offset:offset + limit], "total": len(row.candidates),
|
||||
"limit": limit, "offset": offset, "has_more": offset + limit < len(row.candidates)})
|
||||
|
||||
async def experiments(self, plan_id=None, limit=25, offset=0):
|
||||
query = select(ResearchExperiment).where(ResearchExperiment.kind == "superalpha")
|
||||
if plan_id:
|
||||
query = query.where(ResearchExperiment.evidence["plan_reference"]["id"].as_string() == plan_id)
|
||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
rows = await self.db.scalars(query.order_by(ResearchExperiment.created_at.desc(), ResearchExperiment.id).limit(limit).offset(offset))
|
||||
return encode_snapshot({"items": [{"id": r.id, "name": r.name, "created_at": r.created_at,
|
||||
"total": len(r.candidates)} for r in rows], "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
async def preview(self, experiment_id, candidate_ids):
|
||||
row = await self.db.get(ResearchExperiment, experiment_id)
|
||||
await self.experiment(experiment_id)
|
||||
selected = [c for c in row.candidates if c["client_item_id"] in set(candidate_ids)]
|
||||
if len(selected) != len(set(candidate_ids)):
|
||||
raise HTTPException(422, "候选不属于当前研究记录")
|
||||
return await Backtests(self.db).preview(PreviewInput(inline=DraftInput(name=row.name,
|
||||
candidates=selected, source={**row.evidence["source"], "research_id": row.id})), preserve_source=True)
|
||||
|
||||
async def selection_job(self, args):
|
||||
if bool(args.plan_id) != bool(args.version):
|
||||
raise HTTPException(422, "预览的方案来源需同时指定 ID 和版本")
|
||||
if args.plan_id:
|
||||
await self.assets.get(args.plan_id, args.version, "superalpha_plan")
|
||||
await validate_settings(self.db, [args.settings])
|
||||
account = await self.db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||
if not account or account.connection_status not in ("connected", "expired"):
|
||||
raise HTTPException(409, "请先连接 WorldQuant")
|
||||
payload = args.model_dump(mode="json")
|
||||
jobs = await self.db.scalars(select(Job).where(Job.kind == "super_selection_preview",
|
||||
Job.status.in_(("queued", "running", "waiting_auth", "waiting_connection"))))
|
||||
job = next((j for j in jobs if j.payload == payload and not j.cancel_requested), None)
|
||||
if not job:
|
||||
job = Job(id=str(uuid4()), kind="super_selection_preview", payload=payload, total=1)
|
||||
self.db.add(job)
|
||||
await self.db.flush()
|
||||
return {"job_id": job.id, "status": job.status, "read_with": "get_superalpha_selection"}
|
||||
|
||||
async def alpha(self, alpha_id, limit=25, offset=0):
|
||||
from ..business import Business
|
||||
from ..models import BacktestItem, BacktestResult
|
||||
alpha = await self.db.get(Alpha, alpha_id)
|
||||
if not alpha or alpha.alpha_type != "SUPER":
|
||||
raise HTTPException(404, "SUPER Alpha 尚未导入")
|
||||
item = await self.db.scalar(select(BacktestItem).join(BacktestResult, BacktestResult.item_id == BacktestItem.id)
|
||||
.where(BacktestItem.alpha_id == alpha_id).order_by(BacktestResult.observed_at.desc()).limit(1))
|
||||
components = await actual_components(self.db, item.id, limit, offset) if item else {
|
||||
"status": "unknown", "complete": False, "source": "actual", "items": [], "total": 0}
|
||||
if not item:
|
||||
parsed = parse_components(alpha.raw.get("components", alpha.raw.get("selectedAlphas")))
|
||||
components = {"source": "actual", "status": "available" if parsed["complete"] else "unknown",
|
||||
"complete": parsed["complete"], "component_hash": parsed["component_hash"],
|
||||
"reported_total": parsed["total"], "total": len(parsed["components"]), "warnings": parsed["warnings"],
|
||||
"observed_at": alpha.synced_at, "items": parsed["components"][offset:offset + limit], "limit": limit, "offset": offset}
|
||||
return {**await Business(self.db).get_alpha(alpha_id), "components": components,
|
||||
"descriptions": {k: (alpha.raw.get(k) or {}).get("description", "")
|
||||
if isinstance(alpha.raw.get(k), dict) else "" for k in ("selection", "combo")},
|
||||
"sources": await Business(self.db).get_alpha_sources(alpha_id)}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Cached platform constraints shared by SUPER authoring and generic execution."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
|
||||
|
||||
async def validate_settings(db, values):
|
||||
"""Reject known unsupported values; absent metadata is explicitly unknown, never approved."""
|
||||
snapshot = await ResearchMetadata(db).get("settings")
|
||||
rows = snapshot["content"].get("items", [])
|
||||
if not snapshot["fetched_at"] or not rows:
|
||||
return {"status": "unknown", "reason": "未缓存平台设置"}
|
||||
incomplete = False
|
||||
for settings in values:
|
||||
value = settings.model_dump() if hasattr(settings, "model_dump") else settings
|
||||
matches = [r for r in rows if all(r.get(k) == value.get(v) for k, v in (
|
||||
("instrument_type", "instrumentType"), ("region", "region"), ("universe", "universe"), ("delay", "delay")))]
|
||||
if not matches:
|
||||
raise HTTPException(422, "平台设置快照不支持当前 SUPER 地区 / Universe / Delay 组合")
|
||||
failures = []
|
||||
valid = False
|
||||
for row in matches:
|
||||
failed = []
|
||||
if row.get("neutralizations") and value["neutralization"] not in row["neutralizations"]:
|
||||
failed.append("neutralization")
|
||||
for key, field in row.get("fields", {}).items():
|
||||
if key not in value:
|
||||
continue
|
||||
current = value[key]
|
||||
if "choices" in field and current not in field["choices"]:
|
||||
failed.append(key)
|
||||
if type(current) in (int, float) and (
|
||||
("minimum" in field and current < field["minimum"]) or
|
||||
("maximum" in field and current > field["maximum"])):
|
||||
failed.append(key)
|
||||
if not failed:
|
||||
valid = True
|
||||
incomplete |= any(not row.get("fields", {}).get(key) for key in ("selectionLimit", "selectionHandling", "componentActivation"))
|
||||
break
|
||||
failures.extend(failed)
|
||||
if not valid:
|
||||
raise HTTPException(422, "平台设置快照不支持 SUPER 参数:" + "、".join(sorted(set(failures))))
|
||||
return {"status": "partial" if incomplete else "cached", "fetched_at": snapshot["fetched_at"],
|
||||
"reason": "部分 SUPER 设置范围未提供" if incomplete else "仅按缓存校验,仍需平台执行验证"}
|
||||
@@ -468,6 +468,14 @@ class WqClient:
|
||||
"universe": scope.universe, "delay": scope.delay,
|
||||
})
|
||||
|
||||
async def run_super_selection(self, query):
|
||||
"""Read cnhk super-selection contract with bounded async retries and shared authentication."""
|
||||
allowed = {"selection", "instrumentType", "region", "delay", "selectionLimit", "selectionHandling"}
|
||||
if set(query) != allowed:
|
||||
raise WqError("Selection 参数不完整或包含未知键", "invalid_selection")
|
||||
return await self._read_json("GET", "/simulations/super-selection", params=query, allow_list=True,
|
||||
wait_for_retry_header=True)
|
||||
|
||||
async def research_setting_options(self):
|
||||
"""Snapshot full setting choices for constrained research, including neutralization."""
|
||||
return await self._read_json("OPTIONS", "/simulations")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""SUPER candidates and immutable component evidence; retain all existing Alpha rows."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("backtest_items", sa.Column("alpha_type", sa.String(20), nullable=False, server_default="REGULAR"))
|
||||
op.add_column("backtest_items", sa.Column("selection", sa.Text(), nullable=True))
|
||||
op.add_column("backtest_items", sa.Column("combo", sa.Text(), nullable=True))
|
||||
op.create_index("ix_backtest_items_alpha_type", "backtest_items", ["alpha_type"])
|
||||
op.create_table("super_selection_snapshots",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("job_id", sa.String(36), sa.ForeignKey("sync_jobs.id"), unique=True),
|
||||
sa.Column("item_id", sa.String(36), sa.ForeignKey("backtest_items.id"), unique=True),
|
||||
sa.Column("source", sa.String(20), nullable=False),
|
||||
sa.Column("request", sa.JSON(), nullable=False),
|
||||
sa.Column("request_hash", sa.String(64), nullable=False),
|
||||
sa.Column("component_hash", sa.String(64)),
|
||||
sa.Column("components", sa.JSON(), nullable=False),
|
||||
sa.Column("raw", sa.JSON(), nullable=False),
|
||||
sa.Column("complete", sa.Boolean(), nullable=False),
|
||||
sa.Column("total", sa.Integer()),
|
||||
sa.Column("warnings", sa.JSON(), nullable=False),
|
||||
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False))
|
||||
for key in ("request_hash", "component_hash"):
|
||||
op.create_index(f"ix_super_selection_snapshots_{key}", "super_selection_snapshots", [key])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("super_selection_snapshots")
|
||||
op.drop_index("ix_backtest_items_alpha_type", table_name="backtest_items")
|
||||
for key in ("combo", "selection", "alpha_type"):
|
||||
op.drop_column("backtest_items", key)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Classify snapshots whose sole Alpha failure is the unopened PPAC theme."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Reuse the indexed check_type column; preserve all raw evidence and check stages.
|
||||
table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
|
||||
sa.column("check_type", sa.String(20)))
|
||||
connection = op.get_bind()
|
||||
last_id = None
|
||||
while True:
|
||||
query = sa.select(table.c.id, table.c.checks).order_by(table.c.id).limit(500)
|
||||
if last_id is not None:
|
||||
query = query.where(table.c.id > last_id)
|
||||
rows = connection.execute(query).mappings().all()
|
||||
if not rows:
|
||||
break
|
||||
candidates = []
|
||||
for row in rows:
|
||||
# Freeze the current interpretation instead of importing application code.
|
||||
checks = row["checks"] if isinstance(row["checks"], list) else []
|
||||
failures = [check for check in checks if isinstance(check, dict)
|
||||
and check.get("name") != "REGULAR_SUBMISSION"
|
||||
and isinstance(check.get("result"), str) and check["result"].upper() == "FAIL"]
|
||||
if len(failures) == 1 and failures[0].get("name") == "PURE_POWER_POOL_THEME":
|
||||
candidates.append(row["id"])
|
||||
if candidates:
|
||||
connection.execute(table.update().where(table.c.id.in_(candidates)).values(check_type="PPAC_CANDIDATE"))
|
||||
last_id = rows[-1]["id"]
|
||||
|
||||
|
||||
def downgrade():
|
||||
table = sa.table("alphas", sa.column("check_type", sa.String(20)))
|
||||
op.execute(table.update().where(table.c.check_type == "PPAC_CANDIDATE").values(check_type="FAIL_1"))
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Remove retired template scope metadata without changing execution scopes."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0023"
|
||||
down_revision = "0022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Only structured research documents carry reusable template definitions. Do not
|
||||
# rewrite opaque model messages, audit digests, or already-authorized tool calls.
|
||||
DOCUMENTS = (
|
||||
("research_revisions", ("asset_id", "version"), ("content", "provenance")),
|
||||
("research_experiments", ("id",), ("evidence", "parents")),
|
||||
("research_flow_runs", ("id",), ("definition", "authorization")),
|
||||
("research_step_runs", ("id",), ("output",)),
|
||||
("research_requests", ("id",), ("response",)),
|
||||
)
|
||||
|
||||
|
||||
def remove_template_scope(value):
|
||||
"""Copy structured templates without scope; preserve input scopes and bindings.
|
||||
|
||||
Match the frozen persisted TemplateSpec shape, including nested feature
|
||||
templates and asset snapshots. Never remove an arbitrary key named scope:
|
||||
input snapshots and scope-named expression variables still require it.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
return [remove_template_scope(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
is_template = (
|
||||
isinstance(value.get("name"), str)
|
||||
and isinstance(value.get("expression"), str)
|
||||
and isinstance(value.get("variables"), dict)
|
||||
)
|
||||
return {
|
||||
key: remove_template_scope(item)
|
||||
for key, item in value.items()
|
||||
if not (is_template and key == "scope")
|
||||
}
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Clean template documents in bounded batches, retaining IDs and versions."""
|
||||
connection = op.get_bind()
|
||||
for name, keys, documents in DOCUMENTS:
|
||||
table = sa.table(name, *(
|
||||
[sa.column(key, sa.Integer() if key == "version" else sa.String()) for key in keys]
|
||||
+ [sa.column(column, sa.JSON()) for column in documents]
|
||||
))
|
||||
last = None
|
||||
while True:
|
||||
query = sa.select(table).order_by(*(table.c[key] for key in keys)).limit(500)
|
||||
if last is not None:
|
||||
query = query.where(sa.or_(*(
|
||||
sa.and_(*(table.c[keys[j]] == last[j] for j in range(i)), table.c[key] > last[i])
|
||||
for i, key in enumerate(keys)
|
||||
)))
|
||||
rows = connection.execute(query).mappings().all()
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
changes = {}
|
||||
for column in documents:
|
||||
cleaned = remove_template_scope(row[column])
|
||||
if cleaned != row[column]:
|
||||
changes[column] = cleaned
|
||||
if changes:
|
||||
connection.execute(table.update().where(
|
||||
*(table.c[key] == row[key] for key in keys)
|
||||
).values(**changes))
|
||||
last = tuple(rows[-1][key] for key in keys)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Previous schemas allow absent template scope; deleted metadata cannot be
|
||||
# reconstructed. Execution scopes and all other research data are unchanged.
|
||||
pass
|
||||
@@ -16,10 +16,16 @@ class Platform:
|
||||
self.detail_fail = False
|
||||
self.fail_child = None
|
||||
self.missing = False
|
||||
self.selection_reads = []
|
||||
self.selection_result = {"count": 2, "results": [{"id": "component1", "value": 0.3}, {"id": "component2", "value": 0.7}]}
|
||||
self.actual_components = {"count": 2, "results": [{"id": "component1"}, {"id": "component2"}]}
|
||||
self.secret = "synthetic-platform-secret"
|
||||
|
||||
def __call__(self, request):
|
||||
path = request.url.path
|
||||
if path == "/simulations/super-selection":
|
||||
self.selection_reads.append(dict(request.url.params))
|
||||
return httpx.Response(200, json=self.selection_result)
|
||||
if path == "/authentication":
|
||||
return httpx.Response(201, json={})
|
||||
if path == "/simulations" and request.method == "POST":
|
||||
@@ -39,29 +45,35 @@ class Platform:
|
||||
ids = []
|
||||
for i, item in enumerate(data):
|
||||
child = parent if len(data) == 1 else f"{parent}c{i}"
|
||||
aid = self.existing_alpha_ids[i] if self.existing_alpha_ids else f"alpha{parent}{i}"
|
||||
aid = self.existing_alpha_ids[i] if self.existing_alpha_ids and item["type"] != "SUPER" else f"alpha{parent}{i}"
|
||||
progress = {
|
||||
"status": "COMPLETE",
|
||||
"alpha": aid,
|
||||
"regular": item["regular"],
|
||||
"regular": item.get("regular", ""),
|
||||
"settings": item["settings"],
|
||||
}
|
||||
if i == self.fail_child:
|
||||
progress = {
|
||||
"status": "FAILED",
|
||||
"regular": item["regular"],
|
||||
"regular": item.get("regular", ""),
|
||||
"settings": item["settings"],
|
||||
"message": "invalid expression",
|
||||
}
|
||||
self.simulations[child] = progress
|
||||
self.alphas[aid] = {
|
||||
"id": aid,
|
||||
"regular": {"code": item["regular"]},
|
||||
"regular": {"code": item.get("regular", "")},
|
||||
"type": "REGULAR",
|
||||
"settings": item["settings"],
|
||||
"is": {"sharpe": None, "fitness": 0.8},
|
||||
"status": "UNSUBMITTED",
|
||||
}
|
||||
if item["type"] == "SUPER":
|
||||
assert len(data) == 1, "SUPER must be submitted singly"
|
||||
self.simulations[child].update(type="SUPER", selection=item["selection"], combo=item["combo"])
|
||||
self.simulations[child].pop("regular", None)
|
||||
self.alphas[aid].update(type="SUPER", selection={"code": item["selection"], "description": "Selection rationale"}, combo={"code": item["combo"], "description": "Combo rationale"}, components=self.actual_components)
|
||||
self.alphas[aid].pop("regular", None)
|
||||
ids.append(child)
|
||||
if len(data) > 1:
|
||||
self.simulations[parent] = {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Disposable PostgreSQL compatibility/concurrency acceptance, no external platform calls.
|
||||
|
||||
SUPER_TEST_DATABASE_URL must point to the local wq_superalpha_test database.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.alphas import upsert_alpha
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.models import BacktestItem, Research
|
||||
from app.superalpha.contracts import PlanSave
|
||||
from app.superalpha.service import SuperResearch
|
||||
from app.worldquant import WqClient
|
||||
from tests.backtest_fake import Platform
|
||||
from tests.test_backtests import execute, preview, setup, start
|
||||
from tests.test_superalpha import plan, test_plan_selection_build_versions_and_generic_run
|
||||
|
||||
|
||||
async def seed(settings):
|
||||
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||
async with app.router.lifespan_context(app):
|
||||
_, lane = await setup(app)
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, {"id": "legacy-super", "type": "SUPER", "selection": {"code": "turnover < 0.2"}, "combo": {"code": "1"}})
|
||||
(await db.get(Research, "legacy-super")).note = "keep historical note"
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}) as client:
|
||||
await client.post("/api/v1/auth/login", json={"username": "admin", "password": "synthetic-admin-only"})
|
||||
run = await start(client, await preview(client), "legacy")
|
||||
await execute(app, lane, run["backtest_run_id"])
|
||||
|
||||
|
||||
async def verify(settings):
|
||||
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(Platform())))
|
||||
async with app.router.lifespan_context(app):
|
||||
async with app.state.sessions() as db:
|
||||
assert (await db.get(Research, "legacy-super")).note == "keep historical note"
|
||||
item = await db.scalar(select(BacktestItem))
|
||||
assert item.alpha_type == "REGULAR" and item.selection is None and item.combo is None
|
||||
assert item.persistence_status == "saved"
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}) as client:
|
||||
await client.post("/api/v1/auth/login", json={"username": "admin", "password": "synthetic-admin-only"})
|
||||
await test_plan_selection_build_versions_and_generic_run(app, client)
|
||||
imported = (await client.get("/api/v1/alphas?management_scope=super&q=legacy-super")).json()
|
||||
assert imported["total"] == 1
|
||||
async def save_same():
|
||||
async with app.state.sessions.begin() as db:
|
||||
return await SuperResearch(db).save(PlanSave(plan=plan(), idempotency_key="concurrent-save"))
|
||||
first, second = await asyncio.gather(save_same(), save_same())
|
||||
assert first == second
|
||||
print("PostgreSQL: preserved REGULAR backtest and existing SUPER/notes; SUPER lifecycle and concurrent save replay passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
url = os.environ["SUPER_TEST_DATABASE_URL"]
|
||||
parsed = urlsplit(url)
|
||||
if parsed.hostname not in {"127.0.0.1", "localhost"} or parsed.path != "/wq_superalpha_test":
|
||||
raise SystemExit("Refusing non-local/non-disposable database")
|
||||
key = Fernet.generate_key().decode()
|
||||
os.environ.update(DATABASE_URL=url, ADMIN_PASSWORD="synthetic-admin-only", ENCRYPTION_KEY=key, WQ_EMAIL="", WQ_PASSWORD="")
|
||||
settings = Settings(_env_file=None, database_url=url, admin_password="synthetic-admin-only", encryption_key=key, enable_runner=False, public_origin="http://testserver")
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "head")
|
||||
asyncio.run(seed(settings))
|
||||
command.downgrade(config, "0020")
|
||||
command.upgrade(config, "head")
|
||||
asyncio.run(verify(settings))
|
||||
@@ -162,7 +162,10 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
|
||||
async with ClientSession(streams[0], streams[1]) as client:
|
||||
await client.initialize()
|
||||
listed = await client.list_tools()
|
||||
assert len(listed.tools) == 20
|
||||
assert len(listed.tools) == 35
|
||||
assert {"expand_research_template", "get_template_candidates", "start_template_backtest"} <= {t.name for t in listed.tools}
|
||||
assert {"search_research_templates", "get_research_template", "create_research_template_version"} <= {t.name for t in listed.tools}
|
||||
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
|
||||
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
assert caps.structured_content["max_candidates"] == 100
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Verify distribution through MCP authorization, validation and audit boundaries."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.worldquant import WqClient
|
||||
from tests.test_mcp import credentials, invoke
|
||||
from tests.test_mcp import mcp_app as mcp_app
|
||||
|
||||
|
||||
def row(name, count, region="USA", delay=1):
|
||||
return {"category": {"id": name, "name": name}, "alphaCount": count,
|
||||
"region": region, "delay": delay}
|
||||
|
||||
|
||||
async def test_distribution(mcp_app):
|
||||
calls = []
|
||||
|
||||
def platform(request):
|
||||
calls.append(request)
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/users/self/activities/pyramid-alphas"
|
||||
return httpx.Response(200, json={"pyramids": [row("zero", 0), row("one", 1),
|
||||
row("two", 2), row("three", 3), row("four", 4), row("other", 5, "GLB"),
|
||||
row("delay_zero", 5, delay=0)]})
|
||||
|
||||
client = WqClient(mcp_app.state.settings, transport=httpx.MockTransport(platform))
|
||||
client.credentials, client.authenticated = ("test", "test"), True
|
||||
original = mcp_app.state.runner.client
|
||||
mcp_app.state.runner.client = client
|
||||
try:
|
||||
reader, _ = await credentials(mcp_app, {"research:read"})
|
||||
result = await invoke(mcp_app, reader, "get_pyramid_distribution", {"region": "USA", "delay": 1, "current_date": "2026-09-13"})
|
||||
assert [r["alpha_count"] for r in result["lit"]] == [4, 3]
|
||||
assert [r["remaining"] for r in result["in_progress"]] == [2, 1]
|
||||
assert result["unlit"][0]["category"]["id"] == "zero"
|
||||
assert dict(calls[0].url.params) == {"startDate": "2026-07-01", "endDate": "2026-09-30"}
|
||||
assert result["period"] == {"quarter": "2026-Q3", "start_date": "2026-07-01", "end_date": "2026-09-30"}
|
||||
for args in ({"region": "USA", "delay": 2}, {"region": "USA", "delay": True},
|
||||
{"region": "../", "delay": 1}):
|
||||
response = await mcp_app.state.mcp.invoke(reader, "get_pyramid_distribution", args | {"current_date": "2026-09-13"})
|
||||
assert response.structured_content["error"]["code"] == "INVALID_INPUT"
|
||||
assert len(calls) == 1
|
||||
client.credentials, client.authenticated = None, False
|
||||
response = await mcp_app.state.mcp.invoke(reader, "get_pyramid_distribution", {"region": "USA", "delay": 1, "current_date": "2026-09-13"})
|
||||
assert response.structured_content["error"]["code"] == "DISCONNECTED"
|
||||
finally:
|
||||
mcp_app.state.runner.client = original
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [[], [row("x", None)], [row("x", -1)],
|
||||
[row("x", True)], [row("x", 1), row("x", 2)]])
|
||||
def test_missing_evidence_is_not_zero(rows):
|
||||
from app.research_access.pyramids import distribution
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
distribution({"pyramids": rows}, "USA", 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('value', 'quarter', 'start', 'end'), [
|
||||
('2026-01-01', '2026-Q1', '2026-01-01', '2026-03-31'),
|
||||
('2026-03-31', '2026-Q1', '2026-01-01', '2026-03-31'),
|
||||
('2026-04-01', '2026-Q2', '2026-04-01', '2026-06-30'),
|
||||
('2026-06-30', '2026-Q2', '2026-04-01', '2026-06-30'),
|
||||
('2026-07-01', '2026-Q3', '2026-07-01', '2026-09-30'),
|
||||
('2026-09-30', '2026-Q3', '2026-07-01', '2026-09-30'),
|
||||
('2026-10-01', '2026-Q4', '2026-10-01', '2026-12-31'),
|
||||
('2026-12-31', '2026-Q4', '2026-10-01', '2026-12-31'),
|
||||
('2027-01-01', '2027-Q1', '2027-01-01', '2027-03-31'),
|
||||
('2024-02-29', '2024-Q1', '2024-01-01', '2024-03-31'),
|
||||
])
|
||||
def test_quarter_boundaries(value, quarter, start, end):
|
||||
from datetime import date
|
||||
|
||||
from app.research_access.pyramids import quarter_period
|
||||
|
||||
assert quarter_period(date.fromisoformat(value)) == {
|
||||
'quarter': quarter, 'start_date': start, 'end_date': end}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [None, '2026-02-30', 'not-a-date'])
|
||||
def test_date_required_and_valid(value):
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.research_access.contracts import PyramidQuery
|
||||
|
||||
args = {'region': 'USA', 'delay': 1}
|
||||
if value is not None:
|
||||
args['current_date'] = value
|
||||
with pytest.raises(ValidationError):
|
||||
PyramidQuery.model_validate(args)
|
||||
@@ -100,7 +100,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
|
||||
assert "submit_backtests" not in listed
|
||||
assert not tool.annotations.read_only_hint and not tool.annotations.destructive_hint
|
||||
assert tool.annotations.idempotent_hint and not tool.annotations.open_world_hint
|
||||
assert {"template", "hypothesis", "source_item_ids", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert {"template", "hypothesis", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert tool.input_schema["additionalProperties"] is False
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
assert caps.structured_content["templates"]["create_with"] == TOOL
|
||||
@@ -138,7 +138,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
|
||||
assert {c["expression"] for c in experiment["candidates"]} == {
|
||||
f"rank({field}) + {offset}" for field in ["TEST_FIN_001", "TEST_FIN_002"] for offset in [0, 1, 5]
|
||||
}
|
||||
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
assert experiment["evidence"]["template"]["provenance"] == stored["provenance"]
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
@@ -169,7 +169,7 @@ async def test_template_invalid_inputs_and_missing_sources_are_atomic(app, compl
|
||||
principal, _ = await credentials(app)
|
||||
valid = template_request(completed_source["id"])
|
||||
variants = [
|
||||
{"source_item_ids": []}, {"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 2}, {"hypothesis": " "},
|
||||
{"force": True}, {"idempotency_key": ""},
|
||||
{"template": valid["template"] | {"expression": "rank({missing})"}},
|
||||
@@ -236,3 +236,163 @@ async def test_browser_can_issue_template_only_and_all_permissions(app, logged_i
|
||||
async with app.state.sessions() as db:
|
||||
principal = await authenticate(db, response.json()["token"])
|
||||
assert principal.scopes == scopes
|
||||
|
||||
|
||||
async def test_mcp_template_version_is_idempotent_and_preserves_history(app, completed_source):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request(completed_source["id"])
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
content = deepcopy(body["template"])
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "数据准备中的矩阵字段"}
|
||||
update = {**body, "template": content, "template_id": created["id"],
|
||||
"expected_version": 1, "idempotency_key": "version-2"}
|
||||
tool = "create_research_template_version"
|
||||
first = await invoke(app, principal, tool, update)
|
||||
replay = await invoke(app, principal, tool, update)
|
||||
assert first == replay and first["version"] == 2
|
||||
assert first["combination_count"] is None
|
||||
assert first["provenance"]["parent_template"] == {"id": created["id"], "version": 1}
|
||||
read = await invoke(app, principal, "get_research_template", {"template_id": created["id"], "version": 1})
|
||||
assert read["content"]["variables"]["field"]["values"] == ["TEST_FIN_001", "TEST_FIN_002"]
|
||||
listed = await invoke(app, principal, "search_research_templates", {"q": content["name"]})
|
||||
assert listed["items"][0]["version"] == 2
|
||||
stale = await app.state.mcp.invoke(principal, tool, update | {"idempotency_key": "stale-version"})
|
||||
assert stale.is_error and stale.structured_content["error"]["code"] == "CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchRevision)) == 2
|
||||
denied, _ = await credentials(app, {"research:read"})
|
||||
from fastapi import HTTPException
|
||||
with pytest.raises(HTTPException) as forbidden:
|
||||
await app.state.mcp.invoke(denied, tool, update)
|
||||
assert forbidden.value.status_code == 403
|
||||
|
||||
|
||||
async def test_template_without_result_sources_can_be_created_and_versioned(app):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
assert created["provenance"]["source_items"] == []
|
||||
assert created["validation"]["source_evidence"] == "not_provided"
|
||||
revised = await invoke(app, principal, "create_research_template_version", {
|
||||
**body, "template_id": created["id"], "expected_version": 1, "idempotency_key": "no-source-v2",
|
||||
"source_item_ids": [],
|
||||
})
|
||||
assert revised["version"] == 2 and revised["provenance"]["source_items"] == []
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
|
||||
async def template_collection(app, principal, research_input, count=130):
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
body["template"]["variables"]["field"]["values"] = ["TEST_FIN_001"]
|
||||
body["template"]["variables"]["offset"]["values"] = list(range(count))
|
||||
saved = await invoke(app, principal, TOOL, body)
|
||||
args = {
|
||||
"template_id": saved["id"], "version": saved["version"],
|
||||
"preparation_refs": [{"id": research_input["preparation_id"], "version": research_input["preparation_version"]}],
|
||||
"settings": candidate()["settings"], "limit": count, "idempotency_key": "expand-collection",
|
||||
}
|
||||
return saved, args
|
||||
|
||||
|
||||
async def test_template_collection_mcp_paging_execution_and_replay(app, logged_in, research_input):
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models import BacktestPreview, CatalogResource, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input)
|
||||
async with app.state.sessions.begin() as db:
|
||||
await db.execute(delete(CatalogResource))
|
||||
app.state.runner.backtests.wake.clear()
|
||||
first, retry = await asyncio.gather(*[invoke(app, principal, "expand_research_template", args) for _ in range(2)])
|
||||
assert first == retry and first["total"] == 130 and len(first["items"]) == 25
|
||||
assert first["has_more"] and not first["starts_backtests"]
|
||||
assert first["template"]["id"] == saved["id"] and first["template"]["version"] == 1
|
||||
assert not app.state.runner.backtests.wake.is_set()
|
||||
collected = []
|
||||
for offset in (0, 100):
|
||||
result = await invoke(app, principal, "get_template_candidates", {
|
||||
"experiment_id": first["experiment_id"], "limit": 100, "offset": offset,
|
||||
})
|
||||
collected += result["items"]
|
||||
assert len(collected) == 130 and not result["has_more"]
|
||||
assert all("validation" not in item for item in collected)
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
args = {"experiment_id": first["experiment_id"], "candidate_ids": [c["client_item_id"] for c in collected],
|
||||
"idempotency_key": "execute-collection"}
|
||||
run, replay = await asyncio.gather(*[invoke(app, principal, "start_template_backtest", args) for _ in range(2)])
|
||||
assert run == replay and run["total"] == 130
|
||||
assert run["source"]["kind"] == "template" and run["source"]["research_id"] == first["experiment_id"]
|
||||
assert run["source"]["input_snapshot_ids"]
|
||||
assert app.state.runner.backtests.wake.is_set()
|
||||
rotated, _ = await credentials(app)
|
||||
assert await invoke(app, rotated, "start_template_backtest", args) == run
|
||||
conflict = await app.state.mcp.invoke(principal, "start_template_backtest", args | {"candidate_ids": ["c1"]})
|
||||
assert conflict.is_error and conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
row = await db.get(BacktestRun, run["backtest_run_id"])
|
||||
assert row.ai_context["mcp_token_id"] == principal.token_id
|
||||
audit = await db.scalar(select(MCPAudit).where(MCPAudit.tool == "expand_research_template"))
|
||||
assert audit.business_id == first["experiment_id"]
|
||||
record = (await logged_in.get(f"/api/v1/research/experiments/{first['experiment_id']}")).json()
|
||||
assert record["backtest_run_ids"] == [run["backtest_run_id"]]
|
||||
|
||||
|
||||
async def test_template_mcp_failures_are_atomic_and_do_not_consume_keys(app, research_input):
|
||||
from app.models import BacktestPreview, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input, 2)
|
||||
for changed in [args | {"settings": args["settings"] | {"region": "EUR"}},
|
||||
args | {"preparation_refs": [{**args["preparation_refs"][0], "version": 999}]}]:
|
||||
result = await app.state.mcp.invoke(principal, "expand_research_template", changed)
|
||||
assert result.is_error
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "expand_research_template"))
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
conflict = await app.state.mcp.invoke(principal, "expand_research_template", args | {"seed": 1})
|
||||
assert conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
request = {"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "execute"}
|
||||
for ids in [[], ["unknown"], ["c1", "c1"]]:
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"candidate_ids": ids})
|
||||
assert result.is_error
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"expression": "rank(other)"})
|
||||
assert result.is_error and result.structured_content["error"]["code"] == "INVALID_INPUT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "start_template_backtest"))
|
||||
await invoke(app, principal, "start_template_backtest", request)
|
||||
|
||||
|
||||
async def test_template_tool_discovery_and_execute_permissions(app, research_input):
|
||||
from fastapi import HTTPException
|
||||
|
||||
principal, secret = await credentials(app, {"research:read", "research:write"})
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=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"})).json()
|
||||
tools = {t["name"]: t for t in listed["result"]["tools"]}
|
||||
assert "expand_research_template" in tools and "get_template_candidates" in tools
|
||||
assert "start_template_backtest" not in tools
|
||||
assert "source_item_ids" not in tools[TOOL]["inputSchema"]["required"]
|
||||
assert tools["expand_research_template"]["annotations"]["idempotentHint"]
|
||||
_, args = await template_collection(app, principal, research_input, 2)
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
await app.state.mcp.invoke(principal, "start_template_backtest", {
|
||||
"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "denied",
|
||||
})
|
||||
assert denied.value.status_code == 403
|
||||
reader, _ = await credentials(app, {"research:read"})
|
||||
await invoke(app, reader, "get_template_candidates", {"experiment_id": collection["experiment_id"]})
|
||||
with pytest.raises(HTTPException):
|
||||
await app.state.mcp.invoke(reader, "expand_research_template", args)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""PPAC candidates retain their theme failure and wait for platform eligibility."""
|
||||
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alphas import check_summary, snapshot_columns, upsert_alpha
|
||||
from tests import test_submission
|
||||
from tests.conftest import alpha
|
||||
|
||||
THEME = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
|
||||
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
|
||||
OTHER = {"name": "LOW_SHARPE", "result": "FAIL"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checked", [False, True])
|
||||
@pytest.mark.parametrize("checks,expected", [
|
||||
([THEME], "PPAC_CANDIDATE"),
|
||||
([{**THEME, "result": "fail"}], "PPAC_CANDIDATE"),
|
||||
([THEME, LIMIT, {"name": "MATCHES_THEMES", "result": "WARNING"},
|
||||
{"name": "PROD_CORRELATION", "result": "PENDING"}], "PPAC_CANDIDATE"),
|
||||
([THEME, OTHER], "FAIL_2"),
|
||||
([THEME, THEME], "FAIL_2"),
|
||||
([THEME, {"result": "FAIL"}], "FAIL_2"),
|
||||
([OTHER], "FAIL_1"),
|
||||
([{**THEME, "result": "PASS"}, OTHER], "FAIL_1"),
|
||||
([{**THEME, "result": False}], "PENDING"),
|
||||
([{**THEME, "value": False}], "PPAC_CANDIDATE"),
|
||||
([], "PENDING"),
|
||||
([{}], "PENDING"),
|
||||
])
|
||||
def test_only_a_single_explicit_theme_failure_is_a_candidate(checks, expected, checked):
|
||||
original = copy.deepcopy(checks)
|
||||
columns = snapshot_columns({}, {}, checks, checked=checked)
|
||||
assert columns["check_type"] == expected
|
||||
assert checks == original
|
||||
if expected == "PPAC_CANDIDATE":
|
||||
assert check_summary(checks, check_type=expected)["failed_checks"] == ["PURE_POWER_POOL_THEME"]
|
||||
|
||||
|
||||
async def test_ppac_scope_filters_before_pagination_and_export(app, logged_in):
|
||||
async with app.state.sessions.begin() as db:
|
||||
for name, checks, status, region, hidden in [
|
||||
("candidate1", [THEME], "UNSUBMITTED", "USA", False),
|
||||
("candidate2", [THEME, LIMIT], "UNSUBMITTED", "USA", True),
|
||||
("other_region", [THEME], "UNSUBMITTED", "CHN", False),
|
||||
("submitted", [THEME], "ACTIVE", "USA", False),
|
||||
("missing_status", [THEME], None, "USA", False),
|
||||
("two_failures", [THEME, OTHER], "UNSUBMITTED", "USA", False),
|
||||
("other_failure", [OTHER], "UNSUBMITTED", "USA", False),
|
||||
("passed", [{**THEME, "result": "PASS"}], "UNSUBMITTED", "USA", False),
|
||||
("missing", [], "UNSUBMITTED", "USA", False),
|
||||
]:
|
||||
await upsert_alpha(db, alpha(name, status=status, hidden=hidden, settings={"region": region},
|
||||
**{"is": {"checks": checks}}))
|
||||
query = "ppac_candidate=true®ion=USA&sort=id&direction=asc&limit=1&offset=1"
|
||||
response = await logged_in.get(f"/api/v1/alphas?{query}")
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()
|
||||
assert result["total"] == 2
|
||||
assert [row["id"] for row in result["items"]] == ["candidate2"]
|
||||
assert result["items"][0]["check_type"] == "PPAC_CANDIDATE"
|
||||
assert result["items"][0]["failed_checks"] == ["PURE_POWER_POOL_THEME"]
|
||||
export = await logged_in.get(f"/api/v1/alphas/export?{query}")
|
||||
rows = list(csv.DictReader(io.StringIO(export.text.lstrip("\ufeff"))))
|
||||
assert [row["id"] for row in rows] == ["candidate1", "candidate2"]
|
||||
assert {row["check_type"] for row in rows} == {"PPAC_CANDIDATE"}
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission=SUBMITTED")).json()["total"] == 0
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&check_type=FAIL_1")).json()["total"] == 0
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission_blocked=true")).json()["total"] == 1
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&q=candidate1")).json()["total"] == 1
|
||||
assert (await logged_in.get("/api/v1/alphas?check_type=PPAC_CANDIDATE&submission=UNSUBMITTED")).json()["total"] == 3
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=false")).json()["total"] == 4
|
||||
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={
|
||||
"kind": "view", "content": {"name": "候选PPAC", "filters": {
|
||||
"submission": "UNSUBMITTED", "ppac_candidate": True, "region": "USA",
|
||||
}, "columns": ["name", "check_type"]},
|
||||
})
|
||||
assert saved.status_code in (200, 201), saved.text
|
||||
assert saved.json()["content"]["filters"]["ppac_candidate"] is True
|
||||
|
||||
|
||||
async def test_check_and_sync_refresh_candidate_status_without_losing_evidence(app, logged_in, monkeypatch):
|
||||
await test_submission.setup(app, alpha(**{"is": {"checks": [OTHER]}}))
|
||||
for checks, expected in [([THEME, LIMIT], "PPAC_CANDIDATE"), ([{**THEME, "result": "PASS"}], "PASS")]:
|
||||
monkeypatch.setattr(test_submission, "CHECKS", checks)
|
||||
response = await test_submission.enqueue(logged_in)
|
||||
assert response.status_code == 202, response.text
|
||||
await app.state.runner.execute(response.json()["id"])
|
||||
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
|
||||
assert state["job"]["status"] == "completed"
|
||||
assert state["check_summary"]["check_type"] == expected
|
||||
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
|
||||
assert detail["checks"] == checks
|
||||
assert detail["check_type"] == expected
|
||||
assert detail["research"]["note"] == "preserve local research"
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
|
||||
for checks, expected in [([THEME], "PPAC_CANDIDATE"), ([THEME, OTHER], "FAIL_2")]:
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha(**{"is": {"checks": checks}}))
|
||||
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
|
||||
assert detail["check_type"] == expected
|
||||
assert detail["checks"] == checks
|
||||
assert detail["research"]["note"] == "preserve local research"
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Historical PPAC classification changes only matching cached check types."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
def test_ppac_backfill_preserves_evidence_across_batches_and_downgrade(tmp_path, monkeypatch):
|
||||
database = tmp_path / "ppac.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0021")
|
||||
engine = sa.create_engine(f"sqlite:///{database}")
|
||||
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
|
||||
theme = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
|
||||
other = {"name": "LOW_SHARPE", "result": "FAIL"}
|
||||
patterns = [
|
||||
([theme], "FAIL_1", "PPAC_CANDIDATE"),
|
||||
([{**theme, "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1", "PPAC_CANDIDATE"),
|
||||
([theme, other], "FAIL_2", "FAIL_2"),
|
||||
([theme, theme], "FAIL_2", "FAIL_2"),
|
||||
([other], "FAIL_1", "FAIL_1"),
|
||||
([{**theme, "result": "PASS"}], "PASS", "PASS"),
|
||||
([{**theme, "result": "WARNING"}], "PRE_CHECK", "PRE_CHECK"),
|
||||
([{}], "PENDING", "PENDING"),
|
||||
(None, "PENDING", "PENDING"),
|
||||
]
|
||||
with engine.begin() as db:
|
||||
db.execute(alphas.insert(), [
|
||||
{"id": f"ppac{i:04}", "status": "UNSUBMITTED", "hidden": False, "settings": {}, "os_metrics": {},
|
||||
"is_metrics": {"checks": patterns[i % len(patterns)][0]}, "checks": patterns[i % len(patterns)][0],
|
||||
"check_type": patterns[i % len(patterns)][1], "synced_at": datetime(2026, 9, 13, tzinfo=timezone.utc),
|
||||
"raw": {"is": {"checks": patterns[i % len(patterns)][0]}}}
|
||||
for i in range(503)
|
||||
])
|
||||
original = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
for target, position in [("0022", 2), ("0021", 1), ("0022", 2)]:
|
||||
(command.upgrade if target == "0022" else command.downgrade)(config, target)
|
||||
with engine.connect() as db:
|
||||
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
assert len(rows) == 503
|
||||
for i, row in enumerate(rows):
|
||||
assert dict(row) == {**original[i], "check_type": patterns[i % len(patterns)][position]}
|
||||
command.upgrade(config, "head")
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
@@ -274,3 +274,58 @@ async def test_new_interfaces_require_login_and_same_origin(client):
|
||||
json=construction("none"),
|
||||
)
|
||||
).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["approve", "deny", "tamper", "archive"])
|
||||
async def test_template_collection_single_confirmation(app, logged_in, fixed_input, decision):
|
||||
from app.models import ResearchExperiment
|
||||
from tests.test_research_workspace import expansion
|
||||
|
||||
await setup(app)
|
||||
await configure(app, logged_in)
|
||||
body = expansion(fixed_input["id"])
|
||||
body["template"]["expression"] = "rank({field}) + {offset}"
|
||||
body["template"]["variables"]["offset"] = {"kind": "integer", "values": list(range(20))}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 201, response.text
|
||||
experiment = response.json()
|
||||
assert len(experiment["candidates"]) == 40
|
||||
app.state.ai.model_factory = single_tool_factory("start_template_backtest", {
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [c["client_item_id"] for c in experiment["candidates"]],
|
||||
"idempotency_key": "template-confirm",
|
||||
})
|
||||
conversation = (await logged_in.post("/api/v1/ai/conversations")).json()["id"]
|
||||
run = await ask(logged_in, conversation, "回测这个模板集合")
|
||||
assert run["status"] == "waiting_approval", run
|
||||
assert len(run["tools"]) == 1
|
||||
approval = run["tools"][0]
|
||||
preview = approval["preview"]["backtest"]
|
||||
assert preview["total"] == 40 and len(preview["items"]) < 40
|
||||
async with app.state.sessions.begin() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
if decision == "tamper":
|
||||
saved = await db.get(BacktestPreview, preview["preview_id"])
|
||||
candidates = copy.deepcopy(saved.candidates)
|
||||
candidates[-1]["expression"] = "rank(close)"
|
||||
saved.candidates = candidates
|
||||
elif decision == "archive":
|
||||
saved = await db.get(ResearchExperiment, experiment["id"])
|
||||
saved.archived = True
|
||||
for _ in range(2):
|
||||
result = await logged_in.post(f"/api/v1/ai/approvals/{approval['id']}/decision",
|
||||
json={"approved": decision != "deny"})
|
||||
assert result.status_code == 200, result.text
|
||||
async with app.state.sessions() as db:
|
||||
runs = (await db.scalars(select(BacktestRun))).all()
|
||||
assert len(runs) == (1 if decision == "approve" else 0)
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
if runs:
|
||||
assert runs[0].preview_id == preview["preview_id"]
|
||||
assert runs[0].source["kind"] == "template"
|
||||
assert runs[0].source["research_id"] == experiment["id"]
|
||||
assert runs[0].ai_context["conversation_id"] == conversation
|
||||
assert runs[0].ai_context["ai_run_id"] == run["id"]
|
||||
saved = await db.get(BacktestPreview, runs[0].preview_id)
|
||||
assert len(saved.candidates) == 40
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.catalog.research_metadata import ResearchMetadata
|
||||
from app.models import BacktestRun, CatalogResource, ResearchExperiment
|
||||
from app.research.expressions import analyze, expand
|
||||
from tests.conftest import alpha
|
||||
from tests.test_backtests import execute, setup, start
|
||||
from tests.test_backtests import execute, setup
|
||||
from tests.test_catalog import SCOPE, prepare, sync
|
||||
from tests.test_catalog import catalog as catalog_fixture
|
||||
|
||||
@@ -120,7 +120,7 @@ def test_bounded_sampling_and_repeated_placeholders():
|
||||
expand(expression, values, "all", 100)
|
||||
|
||||
|
||||
async def test_template_version_expansion_preview_and_backtest(app, logged_in, research_input):
|
||||
async def test_template_version_expansion_direct_backtest_and_idempotency(app, logged_in, research_input):
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": template()})
|
||||
assert saved.status_code == 201, saved.text
|
||||
asset = saved.json()
|
||||
@@ -130,7 +130,7 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
assert generated.status_code == 201, generated.text
|
||||
experiment = generated.json()
|
||||
assert len(experiment["candidates"]) == 2
|
||||
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
modified = template()
|
||||
modified["expression"] = "-rank({field})"
|
||||
response = await logged_in.put(
|
||||
@@ -147,10 +147,24 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
)
|
||||
).status_code == 409
|
||||
platform, lane = await setup(app)
|
||||
preview = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})
|
||||
assert preview.status_code == 201, preview.text
|
||||
assert not platform.posts
|
||||
run = await start(logged_in, preview.json(), "research-stage-one")
|
||||
from app.models import BacktestPreview
|
||||
|
||||
url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
|
||||
request = {"candidate_ids": ["c2"], "idempotency_key": "template-confirmation"}
|
||||
result = await logged_in.post(url, json=request)
|
||||
assert result.status_code == 202, result.text
|
||||
run = result.json()
|
||||
assert run["total"] == 1
|
||||
assert run["source"]["kind"] == "template"
|
||||
assert run["source"]["input_snapshot_ids"] == [research_input["id"]]
|
||||
retry = await logged_in.post(url, json=request)
|
||||
assert retry.status_code == 202 and retry.json()["backtest_run_id"] == run["backtest_run_id"]
|
||||
conflict = await logged_in.post(url, json={**request, "candidate_ids": ["c1"]})
|
||||
assert conflict.status_code == 409
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
assert lane.wake.is_set()
|
||||
await execute(app, lane, run["backtest_run_id"])
|
||||
results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
|
||||
aid = results["items"][0]["alpha_id"]
|
||||
@@ -161,22 +175,34 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
assert old["backtest_run_ids"] == [run["backtest_run_id"]]
|
||||
|
||||
|
||||
async def test_invalid_fields_and_unknown_operators_never_start(app, logged_in, research_input):
|
||||
async def test_template_generation_does_not_require_field_operator_or_settings_evidence(app, logged_in, research_input):
|
||||
from sqlalchemy import delete
|
||||
|
||||
async with app.state.sessions.begin() as db:
|
||||
await db.execute(delete(CatalogResource))
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["variables"]["field"]["values"] = ["other_field"]
|
||||
assert (await logged_in.post("/api/v1/research/experiments", json=body)).status_code == 422
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["expression"] = "made_up({field})"
|
||||
body["template"]["expression"] = "made_up({field}) + vec_avg(TEST_FIN_001)"
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 201, response.text
|
||||
eid = response.json()["id"]
|
||||
assert (await logged_in.post(f"/api/v1/research/experiments/{eid}/preview", json={})).status_code == 422
|
||||
experiment = response.json()
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
assert not {"operators_snapshot", "settings_snapshot", "field_availability"} & experiment["evidence"].keys()
|
||||
assert (await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})).status_code == 201
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression", ["rank({field}", "rank({field},,)", "x = {field}"])
|
||||
async def test_template_syntax_errors_reject_whole_collection(app, logged_in, research_input, expression):
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["expression"] = expression
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "语法错误" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
|
||||
|
||||
async def test_import_preview_conflict_and_explicit_commit(logged_in):
|
||||
legacy = {
|
||||
"name": "legacy",
|
||||
@@ -367,7 +393,7 @@ def test_actual_cnhk_setting_choice_nesting_is_supported():
|
||||
)
|
||||
|
||||
|
||||
async def test_published_input_does_not_override_conflicting_field_evidence(app, logged_in, research_input):
|
||||
async def test_template_candidates_ignore_conflicting_field_evidence(app, logged_in, research_input):
|
||||
async with app.state.sessions.begin() as db:
|
||||
await ResearchMetadata(db).publish(
|
||||
"availability-fixture",
|
||||
@@ -382,12 +408,11 @@ async def test_published_input_does_not_override_conflicting_field_evidence(app,
|
||||
experiment = (
|
||||
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
|
||||
).json()
|
||||
assert experiment["candidates"][0]["validation"]["status"] == "needs_review"
|
||||
assert experiment["candidates"][1]["validation"]["status"] == "valid"
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
denied = await logged_in.post(
|
||||
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c1"]}
|
||||
)
|
||||
assert denied.status_code == 422
|
||||
assert denied.status_code == 201
|
||||
duplicate = await logged_in.post(
|
||||
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c2", "c2"]}
|
||||
)
|
||||
@@ -526,3 +551,131 @@ async def test_model_cannot_append_preparation_references_to_fixed_inputs(app, l
|
||||
assert response.status_code == 422 and "不能改变" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted"))
|
||||
|
||||
|
||||
async def test_template_definitions_bind_selected_fields_without_mutating_asset(logged_in, research_input):
|
||||
content = template()
|
||||
content["expression"] = "rank({field}) + rank({field})"
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "横截面字段"}
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
|
||||
assert saved.status_code == 201, saved.text
|
||||
asset = saved.json()
|
||||
assert asset["content"]["variables"]["field"] == {
|
||||
"kind": "field", "field_type": "MATRIX", "description": "横截面字段", "values": [],
|
||||
}
|
||||
body = expansion(research_input["id"], asset_id=asset["id"], version=1, mode="random", limit=2, seed=19)
|
||||
body.pop("template")
|
||||
first = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
second = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert first.status_code == second.status_code == 201, first.text
|
||||
assert first.json()["candidates"] == second.json()["candidates"]
|
||||
assert len(first.json()["candidates"]) == 2
|
||||
for candidate in first.json()["candidates"]:
|
||||
field = candidate["bindings"]["field"]
|
||||
assert research_input["field_types"][field] == "MATRIX"
|
||||
assert candidate["expression"] == f"rank({field}) + rank({field})"
|
||||
stored = (await logged_in.get(f"/api/v1/research/assets/{asset['id']}")).json()
|
||||
assert stored["content"] == asset["content"]
|
||||
|
||||
|
||||
async def test_empty_template_domains_fail_expansion_with_actionable_errors(logged_in, research_input):
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["variables"]["field"] = {"kind": "field", "field_type": "GROUP"}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "没有匹配的 GROUP 字段" in response.text
|
||||
body["template"]["expression"] = "ts_mean(TEST_FIN_001, {window})"
|
||||
body["template"]["variables"] = {"window": {"kind": "integer", "description": "时间窗口"}}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "变量 window 缺少候选取值" in response.text
|
||||
|
||||
|
||||
async def test_native_bot_creates_template_and_immutable_version(app, logged_in):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.ai.capabilities import ToolContext
|
||||
from app.ai.tools import CAPABILITIES
|
||||
from app.business import Business
|
||||
|
||||
async with app.state.sessions.begin() as db:
|
||||
ctx = ToolContext(Business(db))
|
||||
content = template()
|
||||
content["variables"]["field"].pop("values")
|
||||
created = await CAPABILITIES["create_research_template"].invoke(ctx, content)
|
||||
content["description"] = "修订后的研究解释"
|
||||
args = {"asset_id": created["id"], "version": 1, "content": content}
|
||||
updated = await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert updated["version"] == 2
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert conflict.value.status_code == 409
|
||||
old = (await logged_in.get(f"/api/v1/research/assets/{created['id']}?version=1")).json()
|
||||
assert old["content"]["description"] == "测试经济假设"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate_settings", [{"region": "EUR"}, {"universe": "TOP1000"}, {"delay": 0}])
|
||||
async def test_preparation_scope_must_still_match_candidate_settings(app, logged_in, research_input, candidate_settings):
|
||||
body = expansion(research_input["id"])
|
||||
body["input_ids"] = []
|
||||
body["preparation_refs"] = [{"id": research_input["preparation_id"], "version": research_input["preparation_version"]}]
|
||||
body["settings"].update(candidate_settings)
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "输入快照与研究范围不一致" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
|
||||
|
||||
async def test_template_contract_rejects_removed_scope(logged_in):
|
||||
from app.research.workspace_contracts import TemplateSpec
|
||||
|
||||
content = template() | {"scope": SCOPE}
|
||||
response = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
|
||||
assert response.status_code == 422, response.text
|
||||
assert "scope" in response.text
|
||||
assert "scope" not in TemplateSpec.model_json_schema()["properties"]
|
||||
|
||||
|
||||
async def test_template_start_checks_selection_and_ignores_old_row_validation(app, logged_in, research_input):
|
||||
from app.models import BacktestPreview
|
||||
|
||||
experiment = (await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
row = await db.get(ResearchExperiment, experiment["id"])
|
||||
row.candidates = [{**c, "validation": {"status": "needs_review", "syntax": [], "types": [],
|
||||
"availability": ["历史字段未核实"]}} for c in row.candidates]
|
||||
await setup(app)
|
||||
url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
|
||||
for ids in [[], ["unknown"], ["c1", "c1"]]:
|
||||
response = await logged_in.post(url, json={"candidate_ids": ids, "idempotency_key": "confirm-old"})
|
||||
assert response.status_code == 422, response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
response = await logged_in.post(url, json={"candidate_ids": ["c1", "c2"], "idempotency_key": "confirm-old"})
|
||||
assert response.status_code == 202 and response.json()["total"] == 2
|
||||
retry = await logged_in.post(url, json={"candidate_ids": ["c2", "c1"], "idempotency_key": "confirm-old"})
|
||||
assert retry.status_code == 202 and retry.json()["backtest_run_id"] == response.json()["backtest_run_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["syntax", "scope", "archived", "disconnected"])
|
||||
async def test_template_start_rejects_unusable_collection_without_partial_writes(app, logged_in, research_input, change):
|
||||
from app.models import Account, BacktestPreview
|
||||
|
||||
experiment = (await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
row = await db.get(ResearchExperiment, experiment["id"])
|
||||
if change == "syntax":
|
||||
row.candidates = [{**c, "expression": "rank("} for c in row.candidates]
|
||||
elif change == "scope":
|
||||
row.candidates = [{**c, "settings": {**c["settings"], "region": "EUR"}} for c in row.candidates]
|
||||
elif change == "archived":
|
||||
row.archived = True
|
||||
else:
|
||||
(await db.get(Account, 1)).connection_status = "disconnected"
|
||||
app.state.runner.backtests.wake.clear()
|
||||
response = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/backtest",
|
||||
json={"candidate_ids": ["c1"], "idempotency_key": "invalid"})
|
||||
assert response.status_code == (422 if change in ("syntax", "scope") else 409), response.text
|
||||
assert not app.state.runner.backtests.wake.is_set()
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""SUPER public HTTP/MCP acceptance using isolated persistence and synthetic upstream."""
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.backtests.contracts import Candidate
|
||||
from app.models import SimulationAttempt, SuperSelectionSnapshot
|
||||
from app.superalpha.evidence import parse_components
|
||||
from tests.test_backtests import PARAMS, candidate, execute, preview, setup, start
|
||||
from tests.test_mcp import credentials, invoke, mcp_app # noqa: F401
|
||||
|
||||
SETTINGS = PARAMS | {"selectionHandling": "POSITIVE", "selectionLimit": 100, "componentActivation": "IS"}
|
||||
|
||||
|
||||
def super_candidate(index=0, **changes):
|
||||
return {"client_item_id": f"super-{index}", "alpha_type": "SUPER", "selection": f"turnover < {0.1 + index / 10}", "combo": "alpha", "settings": SETTINGS} | changes
|
||||
|
||||
|
||||
def plan(**changes):
|
||||
return {"name": "Super 研究", "hypothesis": "降低组件换手", "selection": "turnover < {threshold}", "combo": "alpha", "variables": {"threshold": {"kind": "number", "values": [0.1, 0.2]}}, "settings": SETTINGS, "include_baseline": True} | changes
|
||||
|
||||
|
||||
async def test_plan_selection_build_versions_and_generic_run(app, logged_in):
|
||||
platform, lane = await setup(app)
|
||||
body = {"plan": plan(), "idempotency_key": "save1"}
|
||||
saved = (await logged_in.post("/api/v1/superalpha/plans", json=body)).json()
|
||||
assert saved["version"] == 1, saved
|
||||
assert (await logged_in.post("/api/v1/superalpha/plans", json=body)).json() == saved
|
||||
selection = {"selection": "turnover < 0.1", "settings": SETTINGS, "plan_id": saved["id"], "version": 1}
|
||||
job = (await logged_in.post("/api/v1/superalpha/selections", json=selection)).json()
|
||||
assert (await logged_in.post("/api/v1/superalpha/selections", json=selection)).json()["job_id"] == job["job_id"]
|
||||
await app.state.runner.run_next()
|
||||
snap = (await logged_in.get(f"/api/v1/superalpha/selections?job_id={job['job_id']}&limit=1")).json()
|
||||
assert snap["complete"] and snap["has_more"] and snap["total"] == 2, snap
|
||||
assert len(platform.selection_reads) == 1 and not platform.posts
|
||||
assert set(platform.selection_reads[0]) == {"selection", "instrumentType", "region", "delay", "selectionLimit", "selectionHandling"}
|
||||
build = {"plan_id": saved["id"], "version": 1, "selection_snapshot_ids": [snap["snapshot_id"]], "idempotency_key": "build1"}
|
||||
exp = (await logged_in.post("/api/v1/superalpha/candidates", json=build)).json()
|
||||
assert exp["total"] == 4, exp
|
||||
assert (await logged_in.post("/api/v1/superalpha/candidates", json=build)).json() == exp
|
||||
# A subset keeps the original experiment's evidence without claiming it applies to every candidate.
|
||||
p = await logged_in.post(f"/api/v1/superalpha/experiments/{exp['id']}/preview", json={"candidate_ids": ["super-2", "super-2-baseline"]})
|
||||
assert p.status_code == 200, p.text
|
||||
assert p.json()["batch_count"] == 2 and not platform.posts
|
||||
rid = (await start(logged_in, p.json()))["backtest_run_id"]
|
||||
await execute(app, lane, rid)
|
||||
result = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()
|
||||
assert all(i["persistence_status"] == "saved" and i["alpha_type"] == "SUPER" for i in result["items"]), result
|
||||
assert len(platform.posts) == 2 and all(len(p) == 1 for p in platform.posts)
|
||||
item = result["items"][0]
|
||||
actual = (await logged_in.get(f"/api/v1/backtests/items/{item['id']}/artifact?kind=components")).json()
|
||||
assert actual["complete"] and actual["source"] == "actual" and actual["snapshot_id"] != snap["snapshot_id"]
|
||||
assert actual["component_hash"] == snap["component_hash"]
|
||||
alpha = (await logged_in.get(f"/api/v1/superalpha/alphas/{item['alpha_id']}")).json()
|
||||
assert alpha["descriptions"]["combo"] == "Combo rationale"
|
||||
assert alpha["sources"]["items"][0]["source"]["superalpha_plan_version"] == 1
|
||||
changed = body | {"plan": plan(name="修订"), "plan_id": saved["id"], "version": 1, "idempotency_key": "save2"}
|
||||
assert (await logged_in.post("/api/v1/superalpha/plans", json=changed)).json()["version"] == 2
|
||||
assert (await logged_in.post("/api/v1/superalpha/plans", json=changed | {"idempotency_key": "save3"})).status_code == 409
|
||||
assert (await logged_in.delete(f"/api/v1/superalpha/plans/{saved['id']}?version=2")).status_code == 200
|
||||
assert (await logged_in.get(f"/api/v1/superalpha/plans/{saved['id']}?version=1")).json()["content"]["name"] == "Super 研究"
|
||||
rows = (await logged_in.get(f"/api/v1/superalpha/experiments/{exp['id']}/results")).json()
|
||||
assert rows["total"] == 2 and rows["items"][0]["pnl_fetched_at"] is None
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(SuperSelectionSnapshot)) == 3
|
||||
|
||||
|
||||
async def test_mixed_run_isolation_facets_export_and_strict_match(app, logged_in):
|
||||
platform, lane = await setup(app)
|
||||
p = await preview(logged_in, [candidate(0), candidate(1), super_candidate(), super_candidate(1)])
|
||||
assert p["batch_count"] == 3
|
||||
rid = (await start(logged_in, p))["backtest_run_id"]
|
||||
await execute(app, lane, rid)
|
||||
assert len(platform.posts) == 3
|
||||
results = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()["items"]
|
||||
assert all(i["persistence_status"] == "saved" for i in results), results
|
||||
for scope, kind in (("super", "SUPER"), ("non_super", "REGULAR")):
|
||||
page = (await logged_in.get(f"/api/v1/alphas?management_scope={scope}")).json()
|
||||
assert page["total"] == 2 and all(i["alpha_type"] == kind for i in page["items"])
|
||||
facets = (await logged_in.get(f"/api/v1/alphas/facets?management_scope={scope}")).json()
|
||||
assert facets["alpha_type"] == [kind], facets
|
||||
export = await logged_in.get(f"/api/v1/alphas/export?management_scope={scope}")
|
||||
assert export.status_code == 200
|
||||
assert all(i["alpha_id"] in export.text for i in results if i["alpha_type"] == kind)
|
||||
assert all(i["alpha_id"] not in export.text for i in results if i["alpha_type"] != kind)
|
||||
rid2 = (await start(logged_in, await preview(logged_in, [super_candidate(9)]), "mismatch"))["backtest_run_id"]
|
||||
async with app.state.sessions() as db:
|
||||
aid = await db.scalar(select(SimulationAttempt.id).where(SimulationAttempt.run_id == rid2))
|
||||
await lane.step(aid)
|
||||
next(reversed(platform.alphas.values()))["combo"]["code"] = "WRONG"
|
||||
await lane.step(aid)
|
||||
row = (await logged_in.get(f"/api/v1/backtests/runs/{rid2}/results")).json()["items"][0]
|
||||
assert row["persistence_status"] != "saved"
|
||||
assert (await logged_in.get("/api/v1/backtests/runs?alpha_type=SUPER")).json()["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reject", ["unknown", "missing_location", "rate"])
|
||||
async def test_super_reliability_no_unknown_resubmission(app, logged_in, reject):
|
||||
platform, lane = await setup(app)
|
||||
platform.reject = reject
|
||||
rid = (await start(logged_in, await preview(logged_in, [super_candidate()])))["backtest_run_id"]
|
||||
async with app.state.sessions() as db:
|
||||
aid = await db.scalar(select(SimulationAttempt.id).where(SimulationAttempt.run_id == rid))
|
||||
for _ in range(app.state.settings.retry_attempts + 1):
|
||||
await lane.step(aid)
|
||||
await asyncio.sleep(0.02)
|
||||
if reject == "rate":
|
||||
assert len(platform.posts) == app.state.settings.retry_attempts
|
||||
else:
|
||||
await lane.start()
|
||||
await lane.stop()
|
||||
await logged_in.post(f"/api/v1/backtests/runs/{rid}/control", json={"action": "recover", "version": 1})
|
||||
await lane.step(aid)
|
||||
assert len(platform.posts) == 1
|
||||
|
||||
|
||||
async def test_unknown_components_and_recovery(app, logged_in):
|
||||
platform, lane = await setup(app)
|
||||
platform.actual_components = None
|
||||
platform.detail_fail = True
|
||||
rid = (await start(logged_in, await preview(logged_in, [super_candidate()])))["backtest_run_id"]
|
||||
ids = await execute(app, lane, rid)
|
||||
platform.detail_fail = False
|
||||
await logged_in.post(f"/api/v1/backtests/runs/{rid}/control", json={"action": "recover", "version": 1})
|
||||
await lane.step(ids[0])
|
||||
item = (await logged_in.get(f"/api/v1/backtests/runs/{rid}/results")).json()["items"][0]
|
||||
assert item["persistence_status"] == "saved" and len(platform.posts) == 1
|
||||
components = (await logged_in.get(f"/api/v1/backtests/items/{item['id']}/artifact?kind=components")).json()
|
||||
assert components["complete"] is False and components["component_hash"] is None
|
||||
|
||||
|
||||
async def test_sampling_validation_and_component_evidence(app, logged_in):
|
||||
await setup(app)
|
||||
for invalid in (super_candidate(selection=" "), super_candidate(combo=""), super_candidate(expression="close"), super_candidate(settings=PARAMS)):
|
||||
with pytest.raises(ValueError):
|
||||
Candidate.model_validate(invalid)
|
||||
assert (await logged_in.get("/api/v1/superalpha/selections")).status_code == 422
|
||||
assert parse_components({"count": 0, "results": []})["complete"]
|
||||
for raw in (["a"], {"count": 2, "results": ["a"]}, {"count": 2, "results": ["a", "a"]}, {"count": 1, "results": ["a"], "next": "next"}):
|
||||
assert not parse_components(raw)["complete"]
|
||||
complete = parse_components({"count": 1, "results": ["a"], "warnings": ["synthetic warning"]})
|
||||
assert complete["complete"] and complete["warnings"] == ["synthetic warning"]
|
||||
args = {"plan": plan(variables={"threshold": {"kind": "number", "values": list(range(100))}}), "mode": "random", "limit": 5, "seed": 42, "idempotency_key": "random1"}
|
||||
a = (await logged_in.post("/api/v1/superalpha/candidates", json=args)).json()
|
||||
b = (await logged_in.post("/api/v1/superalpha/candidates", json=args | {"idempotency_key": "random2"})).json()
|
||||
assert a["candidates"] == b["candidates"] and a["total"] == 10
|
||||
bad = deepcopy(args)
|
||||
bad["plan"]["setting_variants"] = {"selectionLimit": [0]}
|
||||
assert (await logged_in.post("/api/v1/superalpha/candidates", json=bad)).status_code == 422
|
||||
|
||||
|
||||
async def test_mcp_interop_permissions_inline_and_sources(mcp_app): # noqa: F811
|
||||
app = mcp_app
|
||||
principal, _ = await credentials(app, {"research:read", "research:write", "research:refresh", "backtests:execute"})
|
||||
saved = await invoke(app, principal, "save_superalpha_plan", {"plan": plan(), "idempotency_key": "mcp-plan"})
|
||||
built = await invoke(app, principal, "build_superalpha_candidates", {"plan_id": saved["id"], "version": 1, "idempotency_key": "mcp-build"})
|
||||
assert not built["starts_backtests"] and built["total"] == 4
|
||||
assert (await invoke(app, principal, "get_superalpha_plan", {"experiment_id": built["id"], "limit": 1}))["has_more"]
|
||||
submit = {"name": "MCP SUPER", "candidates": built["candidates"][:1], "source": {"research_id": built["id"], "superalpha_plan_id": saved["id"], "superalpha_plan_version": 1}, "duplicate_policy": "rerun", "idempotency_key": "mcp-run"}
|
||||
result = await invoke(app, principal, "submit_backtests", submit)
|
||||
assert result == await invoke(app, principal, "submit_backtests", submit)
|
||||
rid = result["backtest_run_id"]
|
||||
await execute(app, app.state.runner.backtests, rid)
|
||||
results = await invoke(app, principal, "get_backtest_results", {"run_id": rid})
|
||||
item = results["items"][0]
|
||||
assert item["persistence_status"] == "saved", results
|
||||
alpha = await invoke(app, principal, "get_superalpha", {"alpha_id": item["alpha_id"]})
|
||||
assert alpha["alpha_type"] == "SUPER" and "#superalphas?" in alpha["web_url"]
|
||||
forged = deepcopy(submit)
|
||||
forged["idempotency_key"] = "forged"
|
||||
forged["candidates"][0]["combo"] = "WRONG"
|
||||
assert (await app.state.mcp.invoke(principal, "submit_backtests", forged)).is_error
|
||||
direct = submit | {"source": {}, "candidates": [super_candidate(9)], "idempotency_key": "direct"}
|
||||
assert (await invoke(app, principal, "submit_backtests", direct))["source"]["superalpha_plan_id"] is None
|
||||
readonly, _ = await credentials(app, {"research:read"})
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await app.state.mcp.invoke(readonly, "save_superalpha_plan", {"plan": plan(), "idempotency_key": "denied"})
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
async def test_metadata_stage_constraints_selection_recovery_and_cancel(app, logged_in):
|
||||
from app.catalog.research_metadata import ResearchMetadata
|
||||
from app.models import Job
|
||||
from app.superalpha.jobs import run_selection
|
||||
platform, _ = await setup(app)
|
||||
async with app.state.sessions.begin() as db:
|
||||
await ResearchMetadata(db).publish("settings", "settings", {"items": [{"instrument_type": "EQUITY", "region": "USA", "delay": 1, "universe": "TOP3000", "neutralizations": ["INDUSTRY"], "fields": {"selectionLimit": {"maximum": 50}}}]})
|
||||
await ResearchMetadata(db).publish("operators", "operators", {"items": [
|
||||
{"name": "combo_a", "category": "Combo", "scope": ["COMBO"]},
|
||||
{"name": "not_combo", "category": "Other", "scope": ["UNKNOWN_COMBO"]},
|
||||
{"name": "unspecified", "category": "Other", "scope": None}]})
|
||||
assert (await logged_in.post("/api/v1/superalpha/plans", json={"plan": plan(), "idempotency_key": "bad-settings"})).status_code == 422
|
||||
p = await logged_in.post("/api/v1/backtests/previews", json={"inline": {"name": "bad", "candidates": [super_candidate()]}})
|
||||
assert p.status_code == 422
|
||||
assert [o["name"] for o in (await logged_in.get("/api/v1/catalog/operators?stage=COMBO")).json()["items"]] == ["combo_a"]
|
||||
payload = {"selection": "turnover", "settings": SETTINGS | {"selectionLimit": 50}}
|
||||
job = (await logged_in.post("/api/v1/superalpha/selections", json=payload)).json()
|
||||
await app.state.runner.run_next()
|
||||
await run_selection(app.state.runner, job["job_id"], payload) # Restart after commit preserves evidence.
|
||||
assert len(platform.selection_reads) == 1
|
||||
cancelled = (await logged_in.post("/api/v1/superalpha/selections", json=payload)).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
(await db.get(Job, cancelled["job_id"])).cancel_requested = True
|
||||
await app.state.runner.run_next()
|
||||
async with app.state.sessions() as db:
|
||||
assert (await db.get(Job, cancelled["job_id"])).status == "cancelled"
|
||||
assert await db.scalar(select(func.count()).select_from(SuperSelectionSnapshot)) == 1
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Retire template scope once in storage, without a runtime compatibility path."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
Account,
|
||||
ResearchAsset,
|
||||
ResearchExperiment,
|
||||
ResearchFlowRun,
|
||||
ResearchRequest,
|
||||
ResearchRevision,
|
||||
ResearchStepRun,
|
||||
)
|
||||
from app.research.workspace_contracts import FeatureSpec, TemplateSpec
|
||||
|
||||
|
||||
def test_remove_template_scope_preserves_execution_scopes_and_all_versions(tmp_path, monkeypatch):
|
||||
database = tmp_path / "template-scopes.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0022")
|
||||
engine = sa.create_engine(f"sqlite:///{database}")
|
||||
scope = {"instrument_type": "EQUITY", "region": "EUR", "universe": "TOP2500", "delay": 1}
|
||||
clean = {"name": "template", "expression": "rank({scope})", "description": "scope is also a variable name",
|
||||
"variables": {"scope": {"kind": "field", "field_type": "MATRIX", "values": []}}}
|
||||
old = {**clean, "scope": scope}
|
||||
feature = {"name": "feature", "hypothesis": "test", "template": old}
|
||||
clean_feature = {**feature, "template": clean}
|
||||
payload = {"template": {"id": "a", "version": 1, "content": old}, "feature": {"content": feature},
|
||||
"inputs": [{"scope": scope, "field_ids": ["close"]}], "scope": scope,
|
||||
"bindings": {"scope": "close"}, "items": [old, None, "unchanged"]}
|
||||
expected = {**payload, "template": {**payload["template"], "content": clean},
|
||||
"feature": {"content": clean_feature}, "items": [clean, None, "unchanged"]}
|
||||
with Session(engine) as db, db.begin():
|
||||
db.add(Account(id=1))
|
||||
db.add_all([ResearchAsset(id="a", kind="template", name="template", version=501),
|
||||
ResearchAsset(id="b", kind="feature", name="feature", version=2)])
|
||||
db.flush()
|
||||
db.add_all([ResearchRevision(asset_id="a", version=i, content={**old, "scope": scope if i % 2 else None},
|
||||
provenance=payload) for i in range(1, 502)])
|
||||
db.add_all([ResearchRevision(asset_id="b", version=i, content=feature, provenance=payload) for i in (1, 2)])
|
||||
db.add(ResearchExperiment(id="experiment", name="test", kind="template", hypothesis="test",
|
||||
inputs=payload["inputs"], parents=[payload], candidates=[{"settings": scope}], evidence=payload))
|
||||
db.add(ResearchFlowRun(id="flow", request_id="request", name="test", definition=payload, authorization=payload))
|
||||
db.flush()
|
||||
db.add(ResearchStepRun(id="step", run_id="flow", node_id="expand", round=1, output=payload))
|
||||
db.add(ResearchRequest(id="request", account_id=1, operation="create_research_template",
|
||||
idempotency_key="key", digest="a" * 64, business_id="a", response=payload))
|
||||
# Include every stored column in the comparison, not just the removed key.
|
||||
tables = {name: sa.Table(name, sa.MetaData(), autoload_with=engine) for name in (
|
||||
"research_revisions", "research_experiments", "research_flow_runs", "research_step_runs", "research_requests",
|
||||
)}
|
||||
def snapshots():
|
||||
with engine.connect() as db:
|
||||
return {name: [dict(row) for row in db.execute(sa.select(table).order_by(*table.primary_key.columns)).mappings()]
|
||||
for name, table in tables.items()}
|
||||
before = snapshots()
|
||||
wanted = {name: [dict(row) for row in rows] for name, rows in before.items()}
|
||||
for row in wanted["research_revisions"]:
|
||||
row.update(content=clean if row["asset_id"] == "a" else clean_feature, provenance=expected)
|
||||
wanted["research_experiments"][0].update(evidence=expected, parents=[expected])
|
||||
wanted["research_flow_runs"][0].update(definition=expected, authorization=expected)
|
||||
wanted["research_step_runs"][0].update(output=expected)
|
||||
wanted["research_requests"][0].update(response=expected)
|
||||
command.upgrade(config, "head")
|
||||
assert snapshots() == wanted
|
||||
for row in wanted["research_revisions"]:
|
||||
(TemplateSpec if row["asset_id"] == "a" else FeatureSpec).model_validate(row["content"])
|
||||
# Downgrade does not invent the deleted metadata; re-upgrade is idempotent.
|
||||
command.downgrade(config, "0022")
|
||||
assert snapshots() == wanted
|
||||
command.upgrade(config, "head")
|
||||
assert snapshots() == wanted
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
+24
-7
@@ -1,6 +1,17 @@
|
||||
# Independent production configuration; do not merge with compose.yaml.
|
||||
name: wq-alpha-production
|
||||
|
||||
x-observability-labels: &observability-labels
|
||||
observability.logs: "true"
|
||||
observability.project: "wq-alpha"
|
||||
observability.env: "production"
|
||||
|
||||
x-container-logging: &container-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
x-backend: &backend
|
||||
image: wq-alpha-production-backend:${DEPLOY_TAG:-local}
|
||||
build:
|
||||
@@ -21,13 +32,15 @@ x-backend: &backend
|
||||
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||
networks:
|
||||
- default
|
||||
- database
|
||||
# Reach the remote PostgreSQL endpoint through the default network's egress.
|
||||
|
||||
services:
|
||||
backend:
|
||||
<<: *backend
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "backend"
|
||||
logging: *container-logging
|
||||
# Migration is run separately with all application writers stopped.
|
||||
command: [uvicorn, 'app.main:create_app', --factory, --host, 0.0.0.0, --port, '8000', --workers, '1', --proxy-headers]
|
||||
healthcheck:
|
||||
@@ -40,10 +53,18 @@ services:
|
||||
restart: unless-stopped
|
||||
migrate:
|
||||
<<: *backend
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "migrate"
|
||||
logging: *container-logging
|
||||
profiles: [jobs]
|
||||
command: [alembic, upgrade, head]
|
||||
restart: 'no'
|
||||
web:
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "web"
|
||||
logging: *container-logging
|
||||
image: wq-alpha-production-web:${DEPLOY_TAG:-local}
|
||||
build:
|
||||
context: .
|
||||
@@ -66,10 +87,6 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
database:
|
||||
external: true
|
||||
name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network}
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
+29
-14
@@ -1,6 +1,8 @@
|
||||
# Gitea 生产部署
|
||||
|
||||
本方案在目标 Linux 服务器上由已有 Gitea Runner 构建并启动 Docker Compose,复用已有 PostgreSQL。入口绑定 `127.0.0.1:8112`,由宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||
本方案使用两台服务器:A 服务器托管 Gitea 和项目仓库;B 服务器运行 `tencent-prod-runner`,拉取 A 上的 `prod` 分支代码,在 B 上构建镜像并启动 Docker Compose。工作流通过标签 `tencent-prod` 选择该运行器。
|
||||
|
||||
PostgreSQL 位于独立数据库服务器或云数据库,B 上的应用通过 `DATABASE_URL` 访问它,不依赖同机数据库容器或外部 Docker 网络。应用入口绑定 **B 服务器**的 `127.0.0.1:8112`,由 B 的宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||
|
||||
## 1. 填写配置和数据库账号密码
|
||||
|
||||
@@ -8,22 +10,23 @@
|
||||
|
||||
| 位置 | 名称 | 填写内容 |
|
||||
| --- | --- | --- |
|
||||
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` |
|
||||
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@远程数据库域名或IP:端口/数据库名`,地址须能从 B 的应用容器访问 |
|
||||
| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 |
|
||||
| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 |
|
||||
| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 |
|
||||
| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 |
|
||||
| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` |
|
||||
| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
|
||||
| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` |
|
||||
| Variables(可选) | `MCP_ENABLED` | 设置 `true` 启用 MCP;未配置时默认关闭 |
|
||||
|
||||
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。
|
||||
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。原有 `DATABASE_NETWORK` 不再使用,无需在 B 上创建同名外部网络。
|
||||
|
||||
已配置的 `PROD_SSH_KEY`、`PROD_HOST`、`PROD_PORT`、`PROD_USER`、`PROD_KNOWN_HOSTS` 可以继续保留供远程运维使用。本流程由 B 的 Runner 直接部署,不读取这些 SSH Secrets;镜像在 B 本机构建和使用,也不读取 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`。
|
||||
|
||||
数据库连接串示例(实际填写时替换示例值):
|
||||
|
||||
```text
|
||||
postgresql+asyncpg://wq_user:YOUR_PASSWORD@postgresql:5432/wq_alpha
|
||||
postgresql+asyncpg://wq_user:YOUR_PASSWORD@db.example.com:5432/wq_alpha
|
||||
```
|
||||
|
||||
生成加密密钥:
|
||||
@@ -34,7 +37,9 @@ python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_
|
||||
|
||||
`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。
|
||||
|
||||
在现有 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。确认网络存在、数据库别名可解析、PostgreSQL 允许该 Docker 网络访问。应用使用异步驱动 `asyncpg`;远程数据库或强制 TLS 的实例需另外按该实例的 TLS 配置调整连接参数,此模板默认同机 Docker 网络。
|
||||
在远程 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。数据库安全组、白名单或防火墙须允许 B 的实际出口地址访问数据库端口,且 B 的应用容器须能解析并访问数据库地址;使用私网地址时先确保 B 与数据库的私网路由连通。不要在连接串中沿用只在 A 上可解析的数据库容器名,也不要使用 `localhost` 或 `127.0.0.1` 指向远程数据库。
|
||||
|
||||
应用使用异步驱动 `asyncpg`;强制 TLS 或自定义 CA 的实例需按该实例要求配置兼容的连接参数及证书,不应直接假定其控制台提供的其他驱动连接串可用。部署脚本会在停止旧服务前,从 B 的后端容器进行真实数据库连接预检。
|
||||
|
||||
如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。
|
||||
|
||||
@@ -42,19 +47,27 @@ WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面
|
||||
|
||||
## 2. 配置 Gitea Runner
|
||||
|
||||
沿用已成功部署 `zhixing-system` 的运行器标签 **`ubuntu-latest`**,无需注册新的 `wq-production` 运行器。该运行器的执行环境须能通过 Docker CLI/Compose 操作同一台 1Panel 服务器的 Docker daemon;可以复用现有 Docker 连接方式,不强制 host 模式。
|
||||
使用 B 服务器上已注册的 **`tencent-prod-runner`**,工作流的 `runs-on` 填写其标签 **`tencent-prod`**。只有 B 上用于生产部署的运行器应持有此标签。
|
||||
|
||||
运行器的执行环境须通过 Docker CLI/Compose 操作 **B 宿主机的 Docker daemon**。可以沿用现有 host 或 Docker 执行模式:host 模式需要执行用户有权访问 B 的 Docker;容器模式需要部署步骤所在的作业容器能够访问 B 的 Docker socket(通常是 `/var/run/docker.sock`),且具有 Docker CLI/Compose。只给 Runner 管理容器挂载 socket 并不能证明作业容器内也可访问;不要将部署命令连接到 A 的远程 Docker 或临时 Docker-in-Docker 实例。
|
||||
|
||||
Runner 注册的 Gitea 地址及仓库克隆地址必须能从 B 和作业执行环境访问,应使用 A 的实际域名或可达 IP;不能使用指向 A 本机的 `localhost`、`127.0.0.1` 或仅在 A 的 Docker 网络内可解析的容器名。
|
||||
|
||||
执行环境需要 Git、Bash、Node.js 20(checkout v4)以及支持 `up --wait --wait-timeout` 的 Docker Compose。本流程不需要 `flock` 或预建 `/opt/wq-alpha`。服务器需能访问 checkout action、基础镜像仓库和依赖源。
|
||||
|
||||
在仓库启用 Actions,`main` push 或手动运行会部署。Secrets 通过部署步骤的环境变量注入,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。
|
||||
在仓库启用 Actions,向 **`prod`** push 会部署;手动运行也须选择 **`prod`**,选择其他分支时本版工作流会跳过部署任务。Checkout 使用本次触发的提交,构建和迁移均在 B 的执行环境完成,无需 A 通过 SSH 再复制代码到 B。
|
||||
|
||||
本次修改仅在 `prod` 分支。`main` 中的旧工作流仍配置为 `main` push → `ubuntu-latest`,不会随 `prod` 的修改自动停用;如需停用旧部署,须另行修改 `main` 的工作流。
|
||||
|
||||
Secrets 仍在 A 的 Gitea 仓库设置中管理,通过部署步骤的环境变量注入 B 上的作业,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 B 的 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。
|
||||
|
||||
如果 Runner 被强制终止或 Docker 断连,可能留下锁容器。先确认没有本项目部署正在执行,再手动运行 `docker rm wq-alpha-production-deploy-lock` 后重试。不要在部署进行时删除锁。
|
||||
|
||||
## 3. 配置反向代理并首次运行
|
||||
|
||||
为 `PUBLIC_ORIGIN` 对应域名配置 HTTPS,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。代理须运行在宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||
将 `PUBLIC_ORIGIN` 对应域名解析到 **B 服务器**,在 B 配置 HTTPS 反向代理,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。此回环地址只对 B 本机有效;A 上的代理不能通过它访问 B。代理须运行在 B 的宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||
|
||||
首次部署建议在 Gitea Actions 页面手动运行工作流。需要在服务器排障运行时,须先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||
首次部署建议在 Gitea Actions 页面选择 **`prod`** 分支手动运行工作流,确认任务由 `tencent-prod-runner` 接收。需要在服务器排障运行时,在 **B 服务器的 `prod` 检出目录**中先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-production.sh
|
||||
@@ -62,6 +75,8 @@ bash scripts/deploy-production.sh
|
||||
|
||||
固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。
|
||||
|
||||
从旧服务器切换时,若旧实例仍连接同一生产数据库,须在 B 上执行迁移前停止旧实例及其定时任务。脚本和部署锁只管理 B 上的容器,不会停止 A 上的旧应用,也不能协调两台服务器对同一数据库的迁移和写入。
|
||||
|
||||
首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。
|
||||
|
||||
## 4. 升级、备份和失败处理
|
||||
@@ -72,7 +87,7 @@ bash scripts/deploy-production.sh
|
||||
|
||||
如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。
|
||||
|
||||
以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||
以下排查命令在 **B 服务器**运行,不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||
|
||||
```bash
|
||||
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production
|
||||
@@ -84,13 +99,13 @@ docker logs --tail=100 wq-alpha-production-web-1
|
||||
|
||||
## 配置依据
|
||||
|
||||
采用 compound 经验 1 中的生产/开发隔离、显式外部网络、必填凭据、固定项目名、独立迁移和部署健康检查。未照搬旧项目端口、业务 Job 或卷权限初始化:本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||
生产/开发配置隔离,使用必填凭据、固定项目名、独立迁移和部署健康检查。生产服务使用 Compose 默认网络互通,并通过该网络访问远程 PostgreSQL;无需预建数据库 Docker 网络。本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||
|
||||
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 外部网络](https://docs.docker.com/reference/compose-file/networks/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 标签](https://gitea.com/gitea/runner/src/branch/main/README.md)。
|
||||
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 网络](https://docs.docker.com/compose/how-tos/networking/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 2.x 标签](https://docs.gitea.com/runner/2/labels/)、[Gitea 分支上下文](https://docs.gitea.com/usage/actions/actions-variables/)。
|
||||
|
||||
## 5. 1Panel 夜间全量目录同步
|
||||
|
||||
在 1Panel 的计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
||||
在 **B 服务器**的 1Panel 计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
||||
|
||||
```bash
|
||||
docker exec wq-alpha-production-backend-1 \
|
||||
|
||||
+52
-2
@@ -54,7 +54,7 @@ python -m app.cli mcp-token-revoke TOKEN_ID
|
||||
| search_data_preparations | `{q?,scope_key?,limit?,offset?}`;查询可编辑集合及当前版本 |
|
||||
| get_data_preparation | `{id,version,q?,limit?,offset?}`;按版本分页预览字段与数据集归属,版本冲突重新选择 |
|
||||
| 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/superalpha |
|
||||
| refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl |
|
||||
| get_submission_check | `{alpha_id}`;读取检查上下文、snapshot 和缓存结果,不发起检查 |
|
||||
| check_submission | `{alpha_id,snapshot,descriptions}`;写回已确认描述并异步检查,绝不正式提交;要求 research:refresh |
|
||||
@@ -65,7 +65,7 @@ python -m app.cli mcp-token-revoke TOKEN_ID
|
||||
| submit_backtests | `{name,candidates,idempotency_key,preparation_refs?,duplicate_policy?,source?}` |
|
||||
| get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 |
|
||||
| get_backtest_results | `{run_id,item_ids?,limit?,offset?}` |
|
||||
| get_backtest_artifact | `{item_id,kind,limit?,offset?,date_from?,date_to?}`,kind 为 snapshot/pnl |
|
||||
| get_backtest_artifact | `{item_id,kind,limit?,offset?,date_from?,date_to?}`,kind 为 snapshot/pnl/components |
|
||||
| control_backtest | `{run_id,action,expected_version,idempotency_key}` |
|
||||
|
||||
metadata 的 operators 支持 q/category 和分页;settings 支持分页;field_availability 要求 field_id 和 scope。refresh 的 catalog 要求 scope,可选 dataset_id;pnl 要求 alpha_ids;availability 与读取使用相同范围字段。目录和 PnL 刷新返回 job_id,查询不会隐式刷新;另外三种刷新最多等待 30 秒,成功只返回快照引用,完整内容用读取工具获取。失败不发布半成品。
|
||||
@@ -192,3 +192,53 @@ MCP_TEST_DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@127.0.0.1:PORT/wq_mcp_t
|
||||
该脚本执行迁移、并发提交/控制、重启重放、回退及重升级,不用于个人库或生产库。生产启用、真实平台兼容性、真实额度和客户端实际凭据配置仍需另行授权验证。本功能不会恢复任何定时研究。
|
||||
|
||||
使用数据准备集合时,`preparation_refs` 为最多 20 个 `{id,version}`。提交时核对版本、范围和字段,固定独立快照并保存到回测来源;空集合或版本冲突不会创建运行。后续编辑或删除集合不影响回测。无需旧输入草稿接口。
|
||||
|
||||
|
||||
## Super Alpha 研究
|
||||
|
||||
迁移 `0021` 增加回测项类型、Selection/Combo 和独立组件快照表。历史 REGULAR 回测保留原输入及结果;已有 SUPER 记录直接进入 **研究成果 → Super Alpha 管理**。原 Alpha 管理固定为非 SUPER,两个页面的列表、统计、筛选、导出、保存视图和列偏好区分类型,共用同一 Alpha 记录。
|
||||
|
||||
**研究实验 → Super Alpha 研究** 支持方案版本、复制/归档、参数候选、设置候选、等权基线、Selection 预览和固定候选。候选勾选后进入通用回测预览及启动窗口。参数使用 `{name}`;变量不接受普通 Alpha 的 `field` 类型。全量展开默认上限 100,可调至 10000;随机模式使用固定种子。基线另列候选,包含基线后最多 10000 项。保存与构造不调用模型或启动回测。
|
||||
|
||||
| 工具 | 权限与输入 |
|
||||
| --- | --- |
|
||||
| `search_superalpha_plans` | read;q/limit/offset |
|
||||
| `get_superalpha_plan` | read;plan_id/version 或 experiment_id/limit/offset |
|
||||
| `save_superalpha_plan` | write;plan/idempotency_key;更新同时提供 plan_id/version |
|
||||
| `preview_superalpha_selection` | refresh;展开后的 selection、完整 SUPER settings,可选 plan_id/version;立即返回 job_id |
|
||||
| `get_superalpha_selection` | read;snapshot_id 或 job_id,支持 q/limit/offset |
|
||||
| `build_superalpha_candidates` | write;plan_id/version 或内联 plan;mode、limit、seed、selection_snapshot_ids、idempotency_key |
|
||||
| `search_superalphas` | read;filters,服务端固定 SUPER 范围 |
|
||||
| `get_superalpha` | read;alpha_id,读取设置、指标、两个 Description、实际组件及研究来源 |
|
||||
|
||||
权限名称分别为 `research:read`、`research:write`、`research:refresh`。`get_research_metadata({query:{kind:"superalpha"}})` 返回专属设置契约、文档属性和阶段信息;settings 查询可带 alpha_type,operators 可带 stage=SELECTION/COMBO。账户未返回的设置范围保持未知,文档属性不等于实时账户授权。
|
||||
|
||||
外部模型自行构造方案后调用 save/build。构造返回 `candidates` 和 `submit_source`,分别传入 `submit_backtests.candidates` 和 `.source`;超过 100 项按构造记录分页读取,再按批次提交。每批使用独立幂等键,保留同一个 research_id。也可直接提交完整 SUPER 候选,不先保存方案:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "SUPER 等权对照",
|
||||
"idempotency_key": "super-round-001",
|
||||
"candidates": [{
|
||||
"client_item_id": "equal-weight",
|
||||
"alpha_type": "SUPER",
|
||||
"selection": "turnover < 0.2",
|
||||
"combo": "1",
|
||||
"settings": {
|
||||
"instrumentType": "EQUITY", "region": "USA", "universe": "TOP3000", "delay": 1,
|
||||
"decay": 0, "neutralization": "INDUSTRY", "truncation": 0.08,
|
||||
"pasteurization": "ON", "unitHandling": "VERIFY", "nanHandling": "OFF",
|
||||
"language": "FASTEXPR", "visualization": false, "maxTrade": "OFF", "maxPosition": "OFF",
|
||||
"selectionHandling": "POSITIVE", "selectionLimit": 100, "componentActivation": "IS"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
这只是输入形态示例,地区和参数须依据实际元数据选择。回测沿用 `submit_backtests`、`get_backtest`、`get_backtest_results`、`get_backtest_artifact` 和 `control_backtest`。SUPER 每项单独 POST,共用账户并发及恢复机制;REGULAR 继续批量方式。提交回执未知时不会自动重发。`search_backtests` 可按 alpha_type、research_id 筛选;精确匹配覆盖类型、Selection、Combo 和完整设置。
|
||||
|
||||
Selection 预览在通用后台任务中执行,进度用 `get_refresh_job` 查询,组件用 `get_superalpha_selection` 分页读取。预览请求遵循现有平台资料的六个查询字段:selection/instrumentType/region/delay/selectionLimit/selectionHandling。Universe 和 Combo 不会被假装应用到该只读查询;完整设置仍保存在观察记录中。
|
||||
|
||||
预览和实际组件分开存储。`get_backtest_artifact(kind="components")` 仅返回当次实际组件证据;平台未提供完整成员列表时,complete=false、component_hash=null。未核实列表不能作为同池证据,即使预览曾返回完整组件。请求指纹覆盖实际模拟的完整输入,组件指纹覆盖排序后的完整 ID 集合;时间和来源单独记录。指标取当次固定快照,PnL 为单独采集的缓存,以 fetched_at 为准。
|
||||
|
||||
验证入口:`uv run pytest -q tests/test_superalpha.py`、前端 `pnpm exec playwright test tests/superalpha.spec.ts`。专用本地 PostgreSQL 可执行 `SUPER_TEST_DATABASE_URL=.../wq_superalpha_test uv run python -m tests.superalpha_postgres`,验证迁移、历史记录兼容、完整闭环及并发保存幂等。脚本拒绝其他数据库名称或非本机地址。所有自动化验收均使用模拟平台;真实 SUPER 模拟须另行授权。
|
||||
|
||||
@@ -43,3 +43,17 @@ SUE 类方向的可迁移问题是“盈利变化相对于自身历史波动是
|
||||
dateCoverage 只是一项元数据,不告诉你缺失发生在哪几年,也不完整表达横截面质量;VECTOR 非空容器也可能没有有效元素。不要把社区的 40%/60%/80% 建议阈值当官方规则。检查逐年、逐区覆盖及事件时点,比固定门槛更重要。
|
||||
|
||||
来源:[【平台新功能】字段新特征:datecoverage的介绍、应用与勘误。](https://support.worldquantbrain.com/hc/en-us/community/posts/37581194344215)(发帖 2026-01-10,社区经验)。
|
||||
|
||||
## 2026-09-18 补充:代理变量与样本边界
|
||||
|
||||
[搜索量转新闻密度的注意力反转帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43541109631895)(2026-09-16)用新闻事件密度代理搜索关注,作者报告 GBR 与 EUR 表现不同,EUR 变体 Fitness 均低于 1。新闻供给不等于投资者主动搜索,代理替换可能改变机制,应比较原代理、新闻量单腿及价格反转对照;这不是已验证的论文等价复现。
|
||||
|
||||
[MAX 与彩票偏好帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43508107778711)(2026-09-15)报告滚动极端涨幅除以自身波动后,Sharpe 从 0.36 到 0.75,最大回撤从 48.96% 到 5.69%;缩短为 5 日窗口虽进一步提高指标,作者也承认与短期反转重叠。可保留“极值相对自身波动”的待验证假设,但窗口更短、更强不等于彩票机制更纯。本轮未复核论文、借券成本或原始回测,也不将标题中的“完整复现”当作已验证结论。
|
||||
|
||||
[DEU 内部人信念强度帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43178926004887)(2026-09-02,本轮补录)提醒:稀疏事件填零后,大块相同排名可能代表没有事件,不能解释为经济信念中等。作者还报告有效持仓主要在 2018–2023,而较早年份为零;应以实际可投资区间评估结果。比较滚动和与均值时须核对有效观测数和缺失处理;完整固定窗口下两者只差常数,不能笼统断言换成均值必然丢失事件信息。原帖的 tail 阈值与性能改善仅是单例,不推广为稀疏数据通用模板。
|
||||
|
||||
## 2026-09-25 补充:非线性变换与覆盖需要分开检验
|
||||
|
||||
[EUR 财报电话会情绪概率差](https://support.worldquantbrain.com/hc/en-us/community/posts/43620895448343)(2026-09-20)报告 signed power 降相关,并描述早期稀疏截面造成权重集中。随附评论明确指出另一字段的成功不能证明“概率差 + signed_power”有效;应固定其余设置分别对照回填窗、覆盖和 truncation,拆开表达式变换与股票池变化。原帖指标属于作者自报,未复现。
|
||||
|
||||
[ASI 借券费率方差案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624312881943)(2026-09-20)比较方差与标准差版本。即使同一有效样本下平方保持非负值的截面排序,后续线性缩放、中性化或权重处理也可能得到不同持仓,不能由“同一排序”推出整条策略等价。作者由少数 checks 推断的 robust 比率约 0.9 仅是其样本观察,不写成通用平台门槛;每次仍需读取实际 value、limit 和状态。
|
||||
|
||||
@@ -63,3 +63,19 @@ ASI/TOP500 的 risk70 模板使用时间操作后按 exchange 中性化。来源
|
||||
## 10. 当前优先级
|
||||
|
||||
已有九月四张卡覆盖 SNR、机构拥挤、Wikipedia 关注和订单流。新补充方向优先选择“极性配对、同行相对位置、明确口径的实际/预测偏差”中你能取得完整数据的少量假设。优先级属于本库研究建议,未实际运行或比较这些方向的收益。
|
||||
|
||||
## 2026-09-18 补充:差分、正交化与类别编码
|
||||
|
||||
### 同源做差不会保证降低 PC
|
||||
|
||||
[同源信号与噪声对冲](https://support.worldquantbrain.com/hc/en-us/community/posts/43501264459799)(2026-09-15)比较两条弱信号做差和两条强信号做差;[后续讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43536370604567)(2026-09-16)要求拆开两腿验证,但其中“做差一定会让 PC 下降”的说法不成立。数学反例:令 P 与 N 独立且方差相同,A=P+N、B=N,则 A 与 P 的相关系数为 1/√2,而 A−B 与 P 的相关系数为 1。对某些生产池成分,相关性反而会上升。
|
||||
|
||||
保留的实验是 A、B、A−B 分别比较收益、成本、覆盖和与已有池的相关性;两腿都弱也不自动证明共享噪声被成功剥离,需要样本外和稳定性证据。[周频量价概率正交化案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43500671638551)(2026-09-15)自报 Max Prod Corr 在加入运营效率控制后从 0.6806 升至 0.7068,再加国家中性化降至 0.6681。与指定控制变量正交,不等于与整个生产池低相关;作者还报告覆盖及收益损失,应把这些变化同时列入对照,不将单个检查过线称为新机制成立。
|
||||
|
||||
### 分析师差分先对齐经济口径
|
||||
|
||||
[analyst10 预期差异帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43524498271895)(2026-09-16)提出近远期预期差、上调与下调差等候选。可取的是同单位、同口径、只改变一个经济维度的配对方式;覆盖人数与预测 surprise 不能直接相减,已表示净修订的字段也不能未经语义核对再机械差分。120 日回填是作者参数,须另外检查数据可得时点、陈旧观测及两腿实际覆盖,不当作字段的通用配置。
|
||||
|
||||
### 类别编号不是有量纲的数值
|
||||
|
||||
[JPN 另类行业分类协方差帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43308165478935)(2026-09-07,本轮补录)把两套类别整数标签做时间协方差,报告低换手,同时承认解释偏弱和 OS 检查 PENDING。任意置换簇编号可能改变该协方差,而经济分类没有改变,因此不能直接将结果解释为“分类分歧强度”。至少应检验标签重编码不变性,并与基于类别成员关系的一致性度量比较;不同聚类方法也不自动意味着统计独立。本轮没有复现其回测。
|
||||
|
||||
@@ -31,3 +31,11 @@ FastExpr 已能表达的简单机制,先用它建立基线。需要自定义
|
||||
来源:[[Python Alpha 挑战赛]Python Alpha两阶段筛选法:FastExpr快速验证 + Python深度优化](https://support.worldquantbrain.com/hc/en-us/community/posts/41135490599063)(发帖 2026-06-11,社区经验);[从 Fast 翻译到 Python Alpha 别急着炫技:截面/时序错位是头号杀手,事件漂移和中性化层级是隐形雷](https://support.worldquantbrain.com/hc/en-us/community/posts/42047259261335)(发帖 2026-07-18,社区经验);[【经验分享】Python Alpha 30名心得:从 FastExpr 到 Python,我用 17 个 alpha 趟出来的一条路](https://support.worldquantbrain.com/hc/en-us/community/posts/42519002511767)(发帖 2026-08-06,社区经验);[Python Alpha 平台限制粗略小结,请大佬指正](https://support.worldquantbrain.com/hc/en-us/community/posts/40742727142679)(发帖 2026-05-26,社区经验)。
|
||||
|
||||
本次没有安装或运行 BRAIN Labs Python SDK,所以这里只提供官方入口与迁移验证设计,不提供宣称可直接运行的 SDK 脚本。
|
||||
|
||||
## 2026-09-25 补充:频域与 PCA 实现先验算
|
||||
|
||||
[FFT 复现纠错帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43640747372695)(2026-09-21)指出一个可直接验算的恒等式:对同一输入 x 的互补频带,slow + fast = x,因此 slow − x 与 −fast 是同一条腿,混合权重不会产生新信息。这只适用于所示互补分解及一致预处理,不能泛化为所有慢快信号必然共线。
|
||||
|
||||
原帖的其余“修正”仍需审查。对称 Hann 窗末端为零只能推出重构后的 slow[-1] + fast[-1] = 0,不能单凭这一点断言两个分量各自为零或必然衰减固定倍数。正文提倡前值填充,但修正版实际用窗口列均值填缺失;二者不是同一种经济假设。代码 `hist[-WIN:]` 在 WIN=65 时取 65 行,与注释宣称的 66 行也不一致。14–17 倍加速、泄漏比例和回测效果均为作者报告,本库未运行代码。
|
||||
|
||||
[PCA 特质残差反转帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43692787685015)(2026-09-23)可作为待验证的自估风险模型思路,但代码 `cs[H-1:] - cs[:-H+1]` 每项相减的累计索引间隔为 H−1,不能直接标成 H 日收益和;它还将非有限收益填零,需核对缺失、上市历史与股票池影响。作者报告 IND 有效而 EUR/USA/HKG 不佳,并将 PnL 相关 0.963 称作“恒等变换”;这是近似经验,非数学恒等。不得照搬其地区结论或部分正交化系数。
|
||||
|
||||
@@ -41,3 +41,19 @@ CSV 本身不保证并发原子性。需要记录任务身份、租约或领取
|
||||
可迁移的工程做法是先清点候选、小样本检查,再扩大批量;逐项记录源 Alpha、模拟引用和终态。`POST_ERROR` 不能一律视为已处理,未知受理结果须先对账,429 等临时错误与字段不支持多区域的兼容性错误应分别处理。评论补充保持原有 decay、pasteurization 等设置,避免把参数变化误归因于跨区域迁移;新的 universe 口径也要单独核对。
|
||||
|
||||
帖子开头声称批量重跑“一定能找到”可提交候选,但正文也明确完成不代表指标或提交资格保留。本库只保留后一边界;没有可提交结果也不足以单独证明原信号质量低,仍需排查覆盖、设置和区域适用性。
|
||||
|
||||
## 2026-09-18 补充:断点声明与代码行为要对照
|
||||
|
||||
[OS Alpha 批量重跑 RA 两脚本](https://support.worldquantbrain.com/hc/en-us/community/posts/43524503109655)(2026-09-16)声称支持续跑,但所附代码在 `simulate_single` 返回后无条件 `mark_done(k)`,而异常或缺少 Location 的路径可能返回 `None`。这会把未确认完成的任务记为已处理;应保留已受理引用及 UNKNOWN/待对账状态,不能仅靠 done 集合跳过。进度键也没有覆盖全部 settings,改动未入键参数后可能误复用旧进度。这些是静态阅读发现,没有运行脚本或验证平台端行为。
|
||||
|
||||
同帖还包含自动 PATCH Alpha 标签的步骤,因而不是纯读取工具。[另一篇 RA 说明](https://support.worldquantbrain.com/hc/en-us/community/posts/43410200668823)(2026-09-11)所述“不设置属性、不提交”不能自动套用到不同脚本。[AlphaSubmitter](https://support.worldquantbrain.com/hc/en-us/community/posts/43299585796247)(2026-09-07,本轮补录)宣传多轮自动重试,但没有给出足够的幂等或受理状态核对证据;POST 超时不能直接解释为服务端未创建,重发前需先对账。
|
||||
|
||||
[意见留言墙](https://support.worldquantbrain.com/hc/en-us/community/posts/42302637812503)本轮新可见评论提出登录过期导致界面状态丢失、回测次数与 Alpha 列表数量不同等问题。它们是用户反馈,不是已确认平台故障:先保存任务引用与界面筛选状态,再分别核对请求次数、失败/取消状态和结果对象数,避免仅凭两个总数推断丢数据。本轮没有复现相关 UI 行为。
|
||||
|
||||
## 2026-09-25 补充:缺失状态不能被默认值掩盖
|
||||
|
||||
[回测用量帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43058251853847#community_comment_43553064668183)(2026-09-17,本轮补齐)指出,没有匹配到当天活动记录时直接置 used=0,可能把日期错位或接口异常解释成额度充足。适合作为审查项:缺记录保留 UNKNOWN,再核对日期与响应;评论所述日界、固定上限和具体 API 行为未由本轮核验。
|
||||
|
||||
[ProdMemo 桥接帖评论](https://support.worldquantbrain.com/hc/en-us/community/posts/42585946296855#community_comment_43606520549399)(2026-09-19)提出旧缓存覆盖新记录的风险。合并时应保留数据类型、采集时间与来源范围,并核对部分结果是否覆盖完整结果;这是评论提供的待核审查点,本轮没有重新读取并运行桥接脚本,不能直接宣称已确认其实现缺陷。
|
||||
|
||||
[多 Agent 研究流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43623479962007)(2026-09-20)把字段证据、研究假设、实验结果与审计裁定分开,是可用的职责设计:字段存在不证明逻辑成立,回测返回结果不证明通过审计。它是作者的架构方案,不是多代理提高样本外表现的实证。
|
||||
|
||||
@@ -28,3 +28,9 @@
|
||||
[Grand Master 1v1 Advisory 通知](https://support.worldquantbrain.com/hc/en-us/community/posts/43320365312023)(2026-09-08 发布,0 条评论)介绍约 10 分钟答疑,称试运行预约窗口截至 9 月 17 日。可准备一个具体研究瓶颈和证据;本次没有访问预约服务、核对剩余名额或预约。
|
||||
|
||||
[关于第三方发布的论坛公告](https://support.worldquantbrain.com/hc/en-us/community/posts/43319649278359)(2026-09-08 发布,本次读正文及 2 条评论)强调平台内容的对外发布限制,涉及 Alpha、数据、文档及论坛文章。本库记录公告及来源,不作协议效力或个案法律判断;本轮仅本地整理,没有对外发布、推送仓库或改变权限。
|
||||
|
||||
## 2026-09-18 论坛增量
|
||||
|
||||
[ARC2026 社区积分活动通知](https://support.worldquantbrain.com/hc/en-us/community/posts/43498120902807)(2026-09-15)称,CN/HK 顾问按比赛结束时官方榜单统计,成功提交至少 2 个 ARC/RA Parent 可得 50 积分,至少 10 个可得 100 积分,取最高档、不叠加。通知明确这是社区配套活动,与比赛的美元奖池分开。本轮读取通知,未核实个人资格、积分到账或兑换比例,也未提交 Alpha。
|
||||
|
||||
上文 Grand Master 1v1 Advisory 原公布的 9 月 17 日预约窗口已过;截至本次整理,不应继续把它列为已确认开放的预约机会,本轮未核查是否续期。新出现的招聘、免费模型与云服务推广帖保留目录,不将促销额度、模型可用性和邀请收益写成研究结论。
|
||||
|
||||
@@ -26,3 +26,9 @@
|
||||
先排查数据中断与不可投资表现,再按同族/相关簇留代表,然后看同时回撤和成本后的组合增量。旧 Alpha 也可重新评估;但不能只因最近几天排名好就重仓。
|
||||
|
||||
为每次比较固定评价时段和费用设置,记录“原组合”“加入候选”“替换近似旧项”三组结果。正式配点/提交前再确认资格与生效时间。本次没有修改任何平台属性。
|
||||
|
||||
## 2026-09-18 补充:教学倍数与支付公式分开
|
||||
|
||||
[Theme 到 Osmosis 的说明帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43529198653719)(2026-09-16)区分质量因子的加成与每日美元报酬,并明确把“主题合并后再乘 Osmosis”“不分配按 1 倍”作为文章的补充计算口径;这不等于取得完整官方支付公式。[Base Payment 说明帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43561782887575)(2026-09-17)同样声明,四项得分归一化到 0–1 再等权平均只是教学假设。“交满适用配额即 Quantity 满分”也是该帖补充口径,本轮没有独立核实,不能据此计算个人收入或设置自动提交目标。
|
||||
|
||||
[两条高倍数 Alpha 的收入分享](https://support.worldquantbrain.com/hc/en-us/community/posts/43532701388183)(2026-09-16)把两天支付差异归因于提交组合及平均方式;缺少同条件对照,不能据此推导“少交必然更赚钱”或固定的 VF、Osmosis 收入门槛。保留的研究问题是:完整 Alpha 的适用资格、组合贡献和评价窗口是否一致。本文不新增已核实的乘数、资格或支付规则,也不把作者报告视为本次复现。
|
||||
|
||||
@@ -4,7 +4,7 @@ SPC 与“LLM 帮你写 FastExpr”不同:你提交投资论点 Prompt,平
|
||||
|
||||
## 9 月必须更新的输出格式
|
||||
|
||||
7 月中文教程仍写 ISIN|MIC 字典;最新官方提交页(9 月 6 日更新)要求**对象数组**。结构示意如下,尖括号为类型占位符,不能原样提交:
|
||||
7 月中文教程仍写 ISIN|MIC 字典;9 月 7 日首次核查的官方提交页已要求**对象数组**。2026-09-18 再读该页(页面更新 9 月 13 日),要求仍为对象数组。结构示意如下,尖括号为类型占位符,不能原样提交:
|
||||
|
||||
```text
|
||||
[
|
||||
@@ -20,9 +20,9 @@ SPC 与“LLM 帮你写 FastExpr”不同:你提交投资论点 Prompt,平
|
||||
]
|
||||
```
|
||||
|
||||
confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为预期实现收益的天数;return_prediction 表示预测收益率。官方示例用 0.045 表示 4.5%。务必核对证券标识与交易场所,不能用臆造 ID。
|
||||
confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为预期实现收益的天数;return_prediction 表示预期收益变动幅度。9 月 18 日复核的官方文字要求 return_prediction 为正,多空方向由 confidence_score 表示,不能再靠负 return_prediction 表示做空。官方示例用 0.045 表示 4.5%。务必核对证券标识与交易场所,不能用臆造 ID。
|
||||
|
||||
来源:[What do I need to submit?](https://support.worldquantbrain.com/hc/en-us/articles/41681208900759)(官方,更新 2026-09-06);旧格式来源:[【SPC 2026】一文看懂 Systematic Prediction Challenge,写提示词就能参加!](https://support.worldquantbrain.com/hc/en-us/community/posts/41801462727191)(发帖 2026-07-08,社区经验)。
|
||||
来源:[What do I need to submit?](https://support.worldquantbrain.com/hc/en-us/articles/41681208900759)(官方,本次页面更新 2026-09-13,读取 2026-09-18);旧格式来源:[【SPC 2026】一文看懂 Systematic Prediction Challenge,写提示词就能参加!](https://support.worldquantbrain.com/hc/en-us/community/posts/41801462727191)(发帖 2026-07-08,社区经验)。
|
||||
|
||||
## 已核实的规则
|
||||
|
||||
@@ -46,3 +46,9 @@ confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为
|
||||
社区“信号 5–7 天衰减”来自特定经验,不是所有 SPC 机制的固定半衰期。期限应根据来源更新节奏与前瞻验证决定。来源:[【SPC实战】信号衰减5-7天:数据源“时效性”比“全面性”更重要](https://support.worldquantbrain.com/hc/en-us/community/posts/42955680180247)(发帖 2026-08-24,社区经验)。
|
||||
|
||||
本次未调用 LLM 生成投资组合,未提交 Prompt、调整权重或参加比赛。
|
||||
|
||||
## 2026-09-18 补充:社区提交脚本仍需核对版本
|
||||
|
||||
[SPC 提交 API 抓包与自动化帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43322480595991)(2026-09-08,本轮补录)示例仍将 `sampleOutput` 写为 `ISIN|MIC` 字典字符串,与本次官方要求的输出内容不一致。API 包装字段是否需要字符串编码和 Prompt 实际应输出什么,是两个问题;不能因抓包样例曾被接受,就将旧字典视为现行内容规范。
|
||||
|
||||
静态阅读还发现,该帖称已加入调权命令,但展示的命令解析只有登录检查、列表和提交;权重验证检查范围却未落实文中两位小数的声明。示例 POST 超时后没有受理状态对账,人工或自动重试可能造成重复提交。`weight=0` 在官方文字中是停止运行,不应按社区的“下线”表述推断对象被删除。这里只记录版本与恢复边界,未执行脚本、探测提交接口或调整权重。
|
||||
|
||||
@@ -18,3 +18,9 @@
|
||||
大规模搜索时特别检查:收益是否集中在某一年;低 PC 是否只来自缺失或不同日期;高 Sharpe 是否由不可投资的极端持仓产生;同一模板换字段是否仍是同一风险来源;回填是否把旧事件变成持续仓位。
|
||||
|
||||
本知识库中的研究方向没有经过上述实际回测终验,均是待研究候选。
|
||||
|
||||
## 2026-09-25 补充:冻结发生在看结果之前
|
||||
|
||||
[时间前推与冻结流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43638422558103)(2026-09-21,社区方法建议)区分历史分年诊断、时间前推与前瞻观察。看过完整历史后再按年拆分,不能恢复被选择过程用掉的独立性。每个验证段开始前记录表达式、完整 settings、冻结时间和淘汰条件;改动生成新版本,保留失败段,汇总冻结后各段的日收益,而非只展示最好一段或平均各段 Sharpe。
|
||||
|
||||
若标签或持有期跨越分割点,隔离范围应依据实际重叠,而非机械等于最长回看窗口。冻结卡和影子观察是研究建议,不是平台提交规则,也不能据此把个人留出段称为平台隐藏 OS。本轮未复核文中外部论文或运行验证实验。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 来源、覆盖范围与验证记录
|
||||
|
||||
首次采集日期:2026-09-07;最近论坛同步:2026-09-11。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
||||
首次采集日期:2026-09-07;最近论坛同步:2026-09-25(有访问缺口)。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
||||
|
||||
## 邮箱
|
||||
|
||||
@@ -92,3 +92,33 @@ SPC 7 篇官方帮助页按 ID 直接读取;另通过支持站搜索取得 Pyt
|
||||
任务专用采集入口和状态位于本机 `.local/forum-sync/`(Git 忽略):`collect.py` 负责分页、限流退避、互斥和逐帖原子保存;`staged/` 保留去除 HTML 和凭据类字符串的文本;`candidate-baseline.json` 为采集候选,知识整理及验证通过后才复制为 `baseline.json` 并推进 `checkpoint.json`。后续采集比较该基线的正文及评论指纹,仍须实际筛选变化内容。`run.json` 保存本次计数和失败项;部分失败不得宣称全部成功。此状态只包含任务必要内容,不保存登录响应、Cookie 或会话令牌,也未推广为全局规则或共享脚本。
|
||||
|
||||
本轮验证:两个 CSV 各 629 个唯一帖子 ID,评论数合计 9,587;所有论坛引用均属于本次范围,122 篇帖子被知识条目引用,本地链接未发现缺失目标。新增知识内容及本地 JSON 状态的账号、密码字面值与 JWT 形式扫描通过。实际 API 采集通过;采集脚本语法检查通过,后续增量比较、失败恢复和限流退避尚未通过真实故障场景演练,不能将首次成功等同长期运行保证。
|
||||
|
||||
## 每周论坛同步:2026-09-18
|
||||
|
||||
本次实际采集时间为北京时间 10:11:39–10:16:36;这是执行记录,不将计划的 09:00 写成实际开始时间。认证返回 201,支持站 SSO 落地仍为 403,Community API 正常。18 页共有 1,771 篇可见帖子,排除 1,101 篇早于年初边界的帖子,范围内保留 670 篇;上次 629 个 ID 均仍可见。逐帖分页取得 9,601 条评论,670 个记录的实际取得数与列表计数均相等,零采集失败。
|
||||
|
||||
相对 9 月 11 日内容基线,新收录 41 篇:29 篇发表于上次采集结束后,12 篇的创建时间更早。后者只能称为本轮补录,无法仅凭列表差异判断此前为何不可见或漏收。这 41 篇共取得 5 条评论。旧帖留言墙 42302637812503 新可见 9 个评论 ID,其中 7 条创建时间早于上次采集、2 条发表于 9 月 12 日;其余既有评论文本指纹未见变化,也没有缺失评论 ID。评论总数增加 14 条,其中只有 7 条的创建时间晚于上次采集,不能将净增量全称为本周发表。
|
||||
|
||||
231 篇旧帖的更新时间变化,但只有两篇正文内容指纹变化:43320471694999 的 AI 合集新增 CNHKMCP QuantFlow 提示词入口,43030071187479 的新人指南新增第三方发布公告与 Osmosis 入口。逐一对照上周文本确认均为目录更新,没有据此扩充列表外旧帖知识。加上留言墙评论变化,共 3 篇既有帖出现内容或评论集合变化,其余仅同步元数据。
|
||||
|
||||
41 篇新收录帖已完成标题筛查,40 篇有可提取正文并完成主旨阅读,14 篇研究机制和 6 篇工程材料另做独立核验。43287990820375 的正文仅含 12 张图片、没有可提取文字;尝试读取全部 12 个官方图片地址均返回 403,因此未逐图核验,阅读状态记为 `metadata_only`,并在检查点保留待阅读项,不把标题中的夺冠经验或 21 个 OS Alpha 视为已读证据。新收录帖的 5 条评论及留言墙新可见的 9 条评论均已阅读。宣传、充值/邀请和缺少研究证据的收入展示保留索引,不机械扩写成研究方法。
|
||||
|
||||
知识更新包括:同源差分和正交化不保证降低生产相关性、类别编号协方差的编码依赖、分析师差分口径、代理变量及稀疏事件的有效样本边界;RA 脚本未知结果被标为完成的静态风险;支付教学假设与真实公式的区别;ARC 社区积分活动;按评价时间复盘的边界。所有社区回测数值仍为作者自报,本轮未运行或安装其代码。另通过官方 API 复核 SPC 提交说明 41681208900759(更新 2026-09-13),确认对象数组格式,并补充 return_prediction 正值与 confidence_score 多空方向的分工;没有访问提交接口或调整权重。
|
||||
|
||||
本轮继续比较全部范围内评论的文本指纹,没有假定帖子更新时间或评论数量能完全反映评论编辑。评论指纹针对去除 HTML、规范化空白后的文字,不覆盖纯排版、链接目标或图片替换;因此“未见评论文本编辑”不能解释为所有媒体与格式均未变化。上周文本保留在本机 `.local/forum-sync/previous-2026-09-11/`,新候选仍由验证后才推进到相应成功基线,图片访问缺口另行保留;状态与采集文本不包含认证响应或会话材料。没有同步邮件、改变平台属性、预约、报名、回测、发帖或推送仓库。
|
||||
|
||||
验证结果:两个 CSV 各有 670 个唯一帖子 ID,评论合计 9,601;采集记录及候选指纹逐项一致,142 篇帖子被知识条目引用,论坛引用均在范围内,本地链接与差异空白检查通过。新增知识内容和本地 JSON/JSONL 状态的账号密码字面值及 JWT 形式扫描通过。实际 API 采集及本周内容比对已运行;社区回测与代码未执行,自动失败恢复、限流退避仍未经过故障演练。文本及索引同步完成,图片正文访问失败保留为独立缺口。
|
||||
|
||||
## 每周论坛同步:2026-09-25
|
||||
|
||||
实际采集时间为北京时间 09:12:33–09:16:29,随后完成筛读和知识整理。认证返回 201,支持站 SSO 落地 403,Community API 正常。完整取得 19 页、1,859 篇可见帖子,排除 1,101 篇早于年初边界的帖子;本次范围内实际取得 758 篇、10,141 条评论,逐帖分页计数一致,无评论抓取失败。
|
||||
|
||||
相对 9 月 18 日内容基线,新收录 89 篇,其中 72 篇发表于上次采集结束后,17 篇为历史补录。新收录帖带来 9 条评论,217 篇旧帖另有 540 个新可见评论 ID;这 549 条中,67 条发表于上次采集后、482 条创建时间更早,是本轮历史覆盖补齐。旧帖元数据更新时间变化 329 篇,但既有正文指纹无变化,可见帖既有评论没有文本编辑或缺失 ID;没有仅凭更新时间或评论数判断未变。
|
||||
|
||||
此前收录的 41208709211671(傅里叶变换在 Alpha 开发中的应用,2026-06-14)本次列表缺失,直接官方 API 请求也返回 404,原因未定。保留原文快照、引用、索引及其历史 9 条评论,不把 404 解释为已删除。因而当前索引有 759 个 ID:758 个本次可见,1 个历史保留;阅读索引评论数合计 10,150,其中 9 条未在本次重新取得,该帖 comments_complete 记为 False。本次实际取得评论比上次增加 540,等于新可见 549 减去未能重新取得的历史 9,不能将净增量称作新发表评论。
|
||||
|
||||
89 篇新收录帖完成标题及正文主旨筛查,专题目录只作导航,未沿目录扩充 2026 年前帖子;福利、收入展示和未答问题保留索引,不扩写为结论。新收录帖的 9 条评论及旧帖新可见的 540 条评论完成筛读,并针对实质纠错和机制建议精读。新增知识集中在冻结后验证、FFT 互补频带恒等式及修正版局限、PCA 窗口索引、聚类代表不能替代逐项 PC 终验、缺失值语义与 UNKNOWN 状态。社区数值仍为作者自述;本次没有复现回测、安装或执行社区代码,也没有将评论中的配额、评分、时区和阈值升级为官方规则。
|
||||
|
||||
图片帖 43287990820375 仍可取得元数据,正文含 12 张图片;本次抽查其中 1 个官方图片地址仍为 403,没有把抽查写成全部重试,继续保留 metadata_only 和待阅读项。评论指纹仍仅覆盖规范化文字,不检测纯格式、链接目标或图片替换。本次保留上周 670 篇文本快照于本机 previous-2026-09-18/;可见记录验证后推进相应基线,不可访问帖保留旧指纹与重试缺口。此次为可见文本及索引完成、来源与媒体仍有缺口的部分同步,不宣称全量无缺失。
|
||||
|
||||
验证结果:两个 CSV 均有 759 个唯一 ID;758 个本次采集记录与候选指纹、逐评论文本哈希及分页计数一致,另 1 个历史保留记录明确标为当前未完成。154 篇帖子被知识条目引用,引用均属于本次可见范围或明确保留的历史来源。本地链接、差异空白、新增内容及本地 JSON/JSONL 的账号密码字面值和 JWT 形式扫描通过。采集脚本语法检查通过;本轮只验证实际采集与静态内容比对,没有故障注入、策略回测或社区代码执行。
|
||||
|
||||
@@ -29,53 +29,419 @@
|
||||
"No source authentication material is included."
|
||||
],
|
||||
"latest_forum_sync": {
|
||||
"collected_at": "2026-09-11",
|
||||
"collected_at": "2026-09-25",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"started_at": "2026-09-11T01:03:40.336597+00:00",
|
||||
"collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
||||
"raw_visible_posts": 1730,
|
||||
"raw_list_pages": 18,
|
||||
"status": "partial_sync_with_source_and_media_gaps",
|
||||
"started_at": "2026-09-25T01:12:33.054002+00:00",
|
||||
"collection_finished_at": "2026-09-25T01:16:29.789990+00:00",
|
||||
"previous_successful_collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
||||
"raw_visible_posts": 1859,
|
||||
"raw_list_pages": 19,
|
||||
"excluded_older_posts": 1101,
|
||||
"retained_posts": 629,
|
||||
"retained_comments": 9587,
|
||||
"newly_indexed_posts": 6,
|
||||
"newly_indexed_posts_created_sep_7_or_later": 5,
|
||||
"previously_unindexed_sep_5_posts": 1,
|
||||
"retained_posts": 758,
|
||||
"retained_comments": 10141,
|
||||
"indexed_posts_including_unavailable": 759,
|
||||
"historical_comments_retained_for_unavailable_posts": 9,
|
||||
"newly_indexed_posts": 89,
|
||||
"newly_indexed_posts_created_after_previous_collection": 72,
|
||||
"historical_posts_newly_indexed": 17,
|
||||
"comments_on_newly_indexed_posts": 9,
|
||||
"historical_comments_backfilled_on_existing_posts": 6,
|
||||
"comment_count_net_change": 15,
|
||||
"metadata_changed_existing_posts": 141,
|
||||
"body_edit_count": null,
|
||||
"comment_edit_count": null,
|
||||
"failed_comment_records": 0,
|
||||
"missing_previously_indexed_posts": 0,
|
||||
"posts_cited_in_knowledge": 122,
|
||||
"groups": {
|
||||
"engineering": 69,
|
||||
"guides": 7,
|
||||
"events": 39,
|
||||
"general": 96,
|
||||
"ai": 143,
|
||||
"portfolio": 48,
|
||||
"ideas": 19,
|
||||
"lessons": 128,
|
||||
"optimization": 61,
|
||||
"data": 19
|
||||
},
|
||||
"recent_comment_records_screened": 244,
|
||||
"recent_comment_posts_screened": 87,
|
||||
"official_articles_checked": [
|
||||
"43302575764631",
|
||||
"43302563775127",
|
||||
"43302607437719",
|
||||
"43302607372183"
|
||||
"newly_visible_comments_on_existing_posts": 540,
|
||||
"historical_comments_newly_visible": 482,
|
||||
"newly_visible_comments_created_after_previous_collection": 67,
|
||||
"comment_count_net_change": 540,
|
||||
"metadata_changed_existing_posts": 329,
|
||||
"body_changed_existing_post_ids": [],
|
||||
"comment_changed_existing_post_ids": [
|
||||
"37427295710359",
|
||||
"37473718017175",
|
||||
"37759477785239",
|
||||
"37857879534487",
|
||||
"37858237686167",
|
||||
"38120459173143",
|
||||
"38151220803095",
|
||||
"38669179039255",
|
||||
"38767793594775",
|
||||
"39118594803095",
|
||||
"39183798681239",
|
||||
"39215982466071",
|
||||
"39225718749591",
|
||||
"39249078858647",
|
||||
"39304762113815",
|
||||
"39319955780887",
|
||||
"39367839107095",
|
||||
"39567420541079",
|
||||
"39729140385559",
|
||||
"39756841978519",
|
||||
"39845230299927",
|
||||
"39873247817495",
|
||||
"39922788056343",
|
||||
"40031467208983",
|
||||
"40089426663959",
|
||||
"40287368788375",
|
||||
"40373405530647",
|
||||
"40544447671959",
|
||||
"40734437350679",
|
||||
"40734489248151",
|
||||
"40737593669911",
|
||||
"40790666727575",
|
||||
"40847481080087",
|
||||
"40936186021271",
|
||||
"40962462282007",
|
||||
"40964044763031",
|
||||
"41020681850519",
|
||||
"41065497021335",
|
||||
"41138382717719",
|
||||
"41162636720919",
|
||||
"41236357050903",
|
||||
"41318082888343",
|
||||
"41320039211671",
|
||||
"41363895396247",
|
||||
"41391880122007",
|
||||
"41392995550231",
|
||||
"41396933582231",
|
||||
"41397704497303",
|
||||
"41417418697111",
|
||||
"41418196103191",
|
||||
"41420742165527",
|
||||
"41459028441623",
|
||||
"41510344038551",
|
||||
"41540700959639",
|
||||
"41550783184663",
|
||||
"41551045362071",
|
||||
"41551682155799",
|
||||
"41552805539991",
|
||||
"41553665016983",
|
||||
"41556669366551",
|
||||
"41567966789655",
|
||||
"41625263438999",
|
||||
"41651283176855",
|
||||
"41654205318807",
|
||||
"41655387004439",
|
||||
"41684104419095",
|
||||
"41692781472919",
|
||||
"41706827651991",
|
||||
"41719890581911",
|
||||
"41732813691287",
|
||||
"41801462727191",
|
||||
"41804720051607",
|
||||
"41825836449047",
|
||||
"41849925214231",
|
||||
"41854026316055",
|
||||
"41856507417623",
|
||||
"41877348653335",
|
||||
"41877985926423",
|
||||
"41878175264663",
|
||||
"41880799695127",
|
||||
"41881412101399",
|
||||
"41897133795479",
|
||||
"41898002651287",
|
||||
"41898564566167",
|
||||
"41903649174807",
|
||||
"41908323673111",
|
||||
"41909117583383",
|
||||
"41937689411479",
|
||||
"41939959839895",
|
||||
"41949669579031",
|
||||
"41974186627351",
|
||||
"41992899715607",
|
||||
"41993968062743",
|
||||
"41994809192215",
|
||||
"41995369186455",
|
||||
"41995684431767",
|
||||
"42007074836247",
|
||||
"42015743397655",
|
||||
"42018671130391",
|
||||
"42019010387479",
|
||||
"42019332809111",
|
||||
"42019924663319",
|
||||
"42020342676759",
|
||||
"42021244122775",
|
||||
"42021722069143",
|
||||
"42022555985047",
|
||||
"42029118285847",
|
||||
"42045578950551",
|
||||
"42047259261335",
|
||||
"42060935441943",
|
||||
"42061123738903",
|
||||
"42062231758103",
|
||||
"42064544905495",
|
||||
"42064574062359",
|
||||
"42065262268567",
|
||||
"42065802192023",
|
||||
"42078729821591",
|
||||
"42078917602455",
|
||||
"42160836246039",
|
||||
"42160945573271",
|
||||
"42161798745879",
|
||||
"42184733027991",
|
||||
"42185098677399",
|
||||
"42187744015767",
|
||||
"42187906212247",
|
||||
"42211897526295",
|
||||
"42215210781719",
|
||||
"42215740602135",
|
||||
"42216209199255",
|
||||
"42259186135831",
|
||||
"42274443124119",
|
||||
"42302637812503",
|
||||
"42359440646551",
|
||||
"42406158561943",
|
||||
"42407253630359",
|
||||
"42423399281815",
|
||||
"42423993843735",
|
||||
"42455010729367",
|
||||
"42460800447895",
|
||||
"42487308197783",
|
||||
"42509573118999",
|
||||
"42513455206423",
|
||||
"42513832186263",
|
||||
"42519002511767",
|
||||
"42549574041367",
|
||||
"42585946296855",
|
||||
"42587179348119",
|
||||
"42662873714711",
|
||||
"42686571665303",
|
||||
"42691768951447",
|
||||
"42714622464407",
|
||||
"42720875852439",
|
||||
"42721824181271",
|
||||
"42738237663639",
|
||||
"42740521405463",
|
||||
"42743732814999",
|
||||
"42756136847639",
|
||||
"42756641286295",
|
||||
"42757091114647",
|
||||
"42771962021143",
|
||||
"42772611660823",
|
||||
"42774450536343",
|
||||
"42775887653399",
|
||||
"42801904467607",
|
||||
"42945861800983",
|
||||
"42955680180247",
|
||||
"42972530812183",
|
||||
"42975079714839",
|
||||
"42981290349463",
|
||||
"43002825233687",
|
||||
"43006316208151",
|
||||
"43030071187479",
|
||||
"43031425132823",
|
||||
"43031577079575",
|
||||
"43058251853847",
|
||||
"43067045830679",
|
||||
"43085454784535",
|
||||
"43087031637527",
|
||||
"43147371557015",
|
||||
"43151729306647",
|
||||
"43178926004887",
|
||||
"43179521660823",
|
||||
"43179860065303",
|
||||
"43181411147287",
|
||||
"43208281917591",
|
||||
"43269155035799",
|
||||
"43287990820375",
|
||||
"43299585796247",
|
||||
"43309848501783",
|
||||
"43319649278359",
|
||||
"43319751480599",
|
||||
"43320365312023",
|
||||
"43320471694999",
|
||||
"43322521365143",
|
||||
"43322749748503",
|
||||
"43383826751127",
|
||||
"43410200668823",
|
||||
"43411841446423",
|
||||
"43438866840343",
|
||||
"43467733543319",
|
||||
"43477475833623",
|
||||
"43498120902807",
|
||||
"43499065604247",
|
||||
"43500671638551",
|
||||
"43501264459799",
|
||||
"43502970330647",
|
||||
"43508107778711",
|
||||
"43524498271895",
|
||||
"43524503109655",
|
||||
"43527309427863",
|
||||
"43527457608087",
|
||||
"43529198653719",
|
||||
"43529216017047",
|
||||
"43536370604567",
|
||||
"43541109631895",
|
||||
"43556223197335",
|
||||
"43561782887575"
|
||||
],
|
||||
"edited_existing_comments": 0,
|
||||
"missing_existing_comment_ids_on_visible_posts": 0,
|
||||
"failed_comment_records": 0,
|
||||
"missing_previously_indexed_posts": 1,
|
||||
"unavailable_posts": [
|
||||
{
|
||||
"post_id": "41208709211671",
|
||||
"direct_api_status": 404,
|
||||
"historical_comments_retained": 9
|
||||
}
|
||||
],
|
||||
"posts_cited_in_knowledge": 154,
|
||||
"groups": {
|
||||
"guides": 17,
|
||||
"ai": 160,
|
||||
"ideas": 40,
|
||||
"engineering": 86,
|
||||
"general": 137,
|
||||
"lessons": 141,
|
||||
"optimization": 65,
|
||||
"events": 42,
|
||||
"portfolio": 51,
|
||||
"data": 20
|
||||
},
|
||||
"new_post_texts_screened": 89,
|
||||
"metadata_only_post_ids": [
|
||||
"43287990820375"
|
||||
],
|
||||
"new_or_newly_visible_comments_screened": 549,
|
||||
"official_articles_checked": [],
|
||||
"baseline_state_directory": ".local/forum-sync/",
|
||||
"unread_media": [
|
||||
{
|
||||
"post_id": "43287990820375",
|
||||
"image_count": 12,
|
||||
"images_retried_this_run": 1,
|
||||
"http_status": 403
|
||||
}
|
||||
],
|
||||
"limits": [
|
||||
"First content-fingerprint baseline; metadata changes do not establish body edits.",
|
||||
"Historical zero-comment records corrected for three existing posts; net count change is not newly published comments.",
|
||||
"All scoped comments fetched, selective historical body reading; images and attachments not fully verified.",
|
||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence."
|
||||
"758 currently visible posts and 10141 freshly retrieved comments; indexes additionally retain one unavailable post with nine historical comments.",
|
||||
"One previously indexed post returned HTTP 404; its previous fingerprint and records are retained for retry, not deleted or marked freshly complete.",
|
||||
"Newly visible historical posts/comments are backfill; the reason for prior visibility gaps is unknown.",
|
||||
"One image-only post remains unread; one of its twelve official images was retried and returned HTTP 403.",
|
||||
"Comment fingerprints cover normalized text only, not link targets, formatting or media-only changes.",
|
||||
"Community claims and code were reviewed but not executed or backtested; no current platform rules were inferred from comments.",
|
||||
"No email sync, simulation, external publication, submission, credential/session persistence or Git commit/push."
|
||||
]
|
||||
}
|
||||
},
|
||||
"forum_sync_history": [
|
||||
{
|
||||
"collected_at": "2026-09-11",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"started_at": "2026-09-11T01:03:40.336597+00:00",
|
||||
"collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
||||
"raw_visible_posts": 1730,
|
||||
"raw_list_pages": 18,
|
||||
"excluded_older_posts": 1101,
|
||||
"retained_posts": 629,
|
||||
"retained_comments": 9587,
|
||||
"newly_indexed_posts": 6,
|
||||
"newly_indexed_posts_created_sep_7_or_later": 5,
|
||||
"previously_unindexed_sep_5_posts": 1,
|
||||
"comments_on_newly_indexed_posts": 9,
|
||||
"historical_comments_backfilled_on_existing_posts": 6,
|
||||
"comment_count_net_change": 15,
|
||||
"metadata_changed_existing_posts": 141,
|
||||
"body_edit_count": null,
|
||||
"comment_edit_count": null,
|
||||
"failed_comment_records": 0,
|
||||
"missing_previously_indexed_posts": 0,
|
||||
"posts_cited_in_knowledge": 122,
|
||||
"groups": {
|
||||
"engineering": 69,
|
||||
"guides": 7,
|
||||
"events": 39,
|
||||
"general": 96,
|
||||
"ai": 143,
|
||||
"portfolio": 48,
|
||||
"ideas": 19,
|
||||
"lessons": 128,
|
||||
"optimization": 61,
|
||||
"data": 19
|
||||
},
|
||||
"recent_comment_records_screened": 244,
|
||||
"recent_comment_posts_screened": 87,
|
||||
"official_articles_checked": [
|
||||
"43302575764631",
|
||||
"43302563775127",
|
||||
"43302607437719",
|
||||
"43302607372183"
|
||||
],
|
||||
"baseline_state_directory": ".local/forum-sync/",
|
||||
"limits": [
|
||||
"First content-fingerprint baseline; metadata changes do not establish body edits.",
|
||||
"Historical zero-comment records corrected for three existing posts; net count change is not newly published comments.",
|
||||
"All scoped comments fetched, selective historical body reading; images and attachments not fully verified.",
|
||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence."
|
||||
]
|
||||
},
|
||||
{
|
||||
"collected_at": "2026-09-18",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"started_at": "2026-09-18T02:11:39.967012+00:00",
|
||||
"collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
||||
"previous_successful_collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
||||
"raw_visible_posts": 1771,
|
||||
"raw_list_pages": 18,
|
||||
"excluded_older_posts": 1101,
|
||||
"retained_posts": 670,
|
||||
"retained_comments": 9601,
|
||||
"newly_indexed_posts": 41,
|
||||
"newly_indexed_posts_created_after_previous_collection": 29,
|
||||
"historical_posts_newly_indexed": 12,
|
||||
"comments_on_newly_indexed_posts": 5,
|
||||
"newly_visible_comments_on_existing_posts": 9,
|
||||
"historical_comments_newly_visible": 7,
|
||||
"newly_visible_comments_created_after_previous_collection": 7,
|
||||
"comment_count_net_change": 14,
|
||||
"metadata_changed_existing_posts": 231,
|
||||
"body_changed_existing_post_ids": [
|
||||
"43320471694999",
|
||||
"43030071187479"
|
||||
],
|
||||
"comment_changed_existing_post_ids": [
|
||||
"42302637812503"
|
||||
],
|
||||
"edited_existing_comments": 0,
|
||||
"missing_existing_comment_ids": 0,
|
||||
"failed_comment_records": 0,
|
||||
"missing_previously_indexed_posts": 0,
|
||||
"posts_cited_in_knowledge": 142,
|
||||
"groups": {
|
||||
"portfolio": 50,
|
||||
"ai": 154,
|
||||
"ideas": 30,
|
||||
"lessons": 134,
|
||||
"optimization": 62,
|
||||
"data": 20,
|
||||
"events": 41,
|
||||
"engineering": 75,
|
||||
"guides": 8,
|
||||
"general": 96
|
||||
},
|
||||
"new_post_texts_screened": 40,
|
||||
"metadata_only_post_ids": [
|
||||
"43287990820375"
|
||||
],
|
||||
"new_or_newly_visible_comments_read": 14,
|
||||
"official_articles_checked": [
|
||||
{
|
||||
"id": "41681208900759",
|
||||
"updated_at": "2026-09-13T15:38:21Z",
|
||||
"read_at": "2026-09-18"
|
||||
}
|
||||
],
|
||||
"baseline_state_directory": ".local/forum-sync/",
|
||||
"limits": [
|
||||
"Newly visible records can predate the previous run; cause of historical visibility gaps is unknown.",
|
||||
"One new post is image-only and its body has not been visually reviewed; other images and attachments not fully verified.",
|
||||
"Community backtest claims not reproduced; code findings are static, not live platform tests.",
|
||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence.",
|
||||
"Comment fingerprints cover normalized text only, not link destinations, formatting, or embedded media changes."
|
||||
],
|
||||
"unread_media": [
|
||||
{
|
||||
"post_id": "43287990820375",
|
||||
"image_count": 12,
|
||||
"http_status": 403
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -45,3 +45,11 @@
|
||||
增加同类字段可能只是叠加相关噪声和缺失。帖子记录了同域克隆失败、跨类别组合更好的样本,但没有证明多类别组合必然有效。来源:[字段堆越多alpha越牛?我替你们试过了,真不是这么回事](https://support.worldquantbrain.com/hc/en-us/community/posts/41908323673111)(2026-07-13)。
|
||||
|
||||
保持主字段、方向与覆盖语义后,才讨论删除冗余算子;降低 OpCount 不能靠偷偷改变信号。来源:[【Quant101】OpCount 拯救计划:因子化简](https://support.worldquantbrain.com/hc/en-us/community/posts/41105918774807)(2026-06-10)。详见 [优化流程](../08-alpha-optimization/diagnostic-workflow.md)。
|
||||
|
||||
## 2026-09-25 补充:缺失设置是实验变量
|
||||
|
||||
[按数据类型选择回填](https://support.worldquantbrain.com/hc/en-us/community/posts/43273417454487)(2026-09-05,本轮补录)和 [NanHandling 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43562519639063)(2026-09-17,本轮补录)提醒,事件未发生、状态尚未更新与采集缺失不能用同一填法。NanHandling ON/OFF、算子及字段组合可能改变有效股票数和信号含义;文中观测不是平台所有算子的统一定义,尤其不能推广“其他情况无所谓”。
|
||||
|
||||
[新闻密度帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43541109631895#community_comment_43582240044695)(评论发表于 2026-09-18)提出:若无新闻日为缺失且均值忽略缺失,得到的是有新闻日的条件均值,并非每个交易日的新闻密度。应先核对实际返回值,再比较零填充、原缺失和有限回填,同时观察覆盖与集中度;不能预先把缺失都当无事件。[预处理讨论评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43527457608087#community_comment_43552980010775)(2026-09-17,本轮补齐)进一步提醒,回填后的 Sharpe 提升可能只是股票构成改变,需并看信号年龄、多空数量、换手及分组暴露。这些是评论者的机制建议,不是已确认因果结果。
|
||||
|
||||
历史频域来源 41208709211671 在本次列表缺失,2026-09-25 直接 API 请求返回 404;保留此前归纳和来源,不据此判断作者删除或内容失效。
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
Osmosis 社区帖称有多个 Region×Delay、每赛道 Alpha 数量与点数要求;具体资格和锁定时区未由本轮完整核实,不将该帖直接升级为当前官方规则。来源:[🌟 玩转 Osmosis:让你的 Alphas 成为最高 2 倍收益放大器!(机制详解与实操指南)](https://support.worldquantbrain.com/hc/en-us/community/posts/43002825233687)(2026-08-26)。
|
||||
|
||||
关于低 PC 与高 Fitness 的取舍,社区有单变量实验支持按质量预算思考,不能只追最低 PC。来源:[关于降Prod Correlation的实践(一)](https://support.worldquantbrain.com/hc/en-us/community/posts/39118594803095)(2026-03-18)。具体权重和阈值需要依据你的池验证,本任务未修改组合、提交 Alpha 或分配点数。
|
||||
|
||||
## 2026-09-25 补充:聚类代表不能替代每个候选的终验
|
||||
|
||||
[本地 PnL 聚类预筛工具](https://support.worldquantbrain.com/hc/en-us/community/posts/43300341649687)(2026-09-07,本轮补录)按候选间相关性合并簇,只查代表项并把 PC 结果传播给成员。减少重复检查可作为调度思路,但 PASS/FAIL 传播没有充分保证。以同一日期样本上中心化、标准化的 A、B、P 为例,柯西不等式给出 |corr(A,P)−corr(B,P)| ≤ √(2(1−corr(A,B)));即使 corr(A,B)=0.9,上界仍约 0.447,远不能保证跨不过提交门槛。这是本库数学边界,不是平台 PC 公式;链式连通聚类还不保证任意两成员都达到聚类阈值。
|
||||
|
||||
[双桶去相关案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624302417559)(2026-09-20)报告 PC 下降同时 margin 下降,也给出分桶轴与信号重叠导致信号被抹去的反例。因此比较应同时记录成本、覆盖、子区域与生产相关性,不能只凭某项过线判定改善。[Value Factor 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43589077012759)(2026-09-18)区分单体质量、自有池增量和平台全池评价;其教学示例不是实测收益或支付公式。上述数值和工具效果均未复现。
|
||||
|
||||
@@ -32,3 +32,9 @@
|
||||
触发条件;完整基线;失败原值;已经试过的修复;对照;新的失败项;适用边界;是否复现;下次何时允许重试。把网络故障、语义错误、检查失败和经济假设被否定分成不同类别。
|
||||
|
||||
本库是当前任务交付物,没有将这些社区经验自动写入全局 Skill、Hook 或硬性提交规则。
|
||||
|
||||
## 2026-09-18 补充:复盘要对齐评价时间
|
||||
|
||||
[OS Comb 六月至七月复盘](https://support.worldquantbrain.com/hc/en-us/community/posts/43438866840343)(2026-09-12)只讨论已更新的六月 3.3+、七月 3.5+,明确八月未刷新,不把后续研究工作解释为此前指标上升的原因。作者把变化归于跨区、多品类和入池质量;这是个人归因,未隔离市场环境、旧池变化和平台统计窗口。可保留的方法是给研究动作、提交入池日和指标评价期分别标记时间,避免倒置因果。
|
||||
|
||||
[负 Combine 到 GM 的复盘上篇](https://support.worldquantbrain.com/hc/en-us/community/posts/43540397996311)及[下篇](https://support.worldquantbrain.com/hc/en-us/community/posts/43540418549527)(均为 2026-09-16)再次强调过提交检查不等于成本后质量、复杂度与多样性需要分开看。晋级与收入结果均为作者自报,不能将成功者经历称为保证可复制的方法,也不能把地区或数据类别数量直接当作有效分散度。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# WorldQuant 研究重启知识库
|
||||
|
||||
首次整理:2026-09-07;论坛最近同步:2026-09-11,Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
||||
首次整理:2026-09-07;论坛最近同步:2026-09-25(有访问缺口),Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
||||
|
||||
9 月 11 日增量:论坛索引现有 **629 篇帖子、9,587 条评论**,新增收录 6 篇(其中 1 篇为 9 月 5 日漏收来源),并补齐原有三篇帖子的 6 条历史评论。新增 ARC2026 官方 FAQ 复核、跨区域重跑经验及论坛公告,见 [平台更新](04-platform/current-updates.md) 和 [工程经验](03-engineering/2026-operating-lessons.md)。首次完整比较基线已建立;旧帖更新时间变化不能直接等同正文编辑,详见 [来源与覆盖](06-sources/README.md)。
|
||||
9 月 25 日增量:本次实际取得 **758 篇可见帖子、10,141 条评论**。新收录 89 篇,其中 72 篇发表于上次采集后、17 篇为历史补录;217 篇旧帖出现新可见评论,未见既有正文或评论文本编辑。更新[时间前推验证](05-research-practice/validation.md)、[FFT/PCA 实现边界](02-ai-mining/python-alpha.md)、[相关性预筛](08-alpha-optimization/portfolio-and-correlation.md)、[数据语义](07-professional-knowledge/data-and-signal-semantics.md)、[工程状态](03-engineering/2026-operating-lessons.md)和[事件信号对照](01-alpha-ideas/data-template-cards.md)。另有 1 篇旧帖返回 404,历史记录仍保留,索引共 759 篇;1 篇图片正文继续待读。详见[来源与覆盖](06-sources/README.md)。
|
||||
|
||||
**先更新研究方向,再恢复回测规模。** 当前最值得投入的是有经济解释的低相关信号、能保留失败证据的 AI 实验闭环,以及组合层的增量价值。不要把“提交数量”“Token 消耗”当作研究进展。
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SuperAlphaResearchPage } from "./superalpha/SuperAlphaResearchPage";
|
||||
import { FieldDirectory } from "./preparations/FieldDirectory";
|
||||
import { DataPreparationPage } from "./preparations/DataPreparationPage";
|
||||
import { SnapshotDialog } from "./preparations/SnapshotDialog";
|
||||
@@ -66,6 +67,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
[
|
||||
"superalpha-research",
|
||||
"operators",
|
||||
"templates",
|
||||
"variants",
|
||||
@@ -101,6 +103,9 @@ export default function App() {
|
||||
const [alphaContext, setAlphaContext] = useState<PageContext>({
|
||||
page: "alphas",
|
||||
});
|
||||
const [superAlphaContext, setSuperAlphaContext] = useState<PageContext>({
|
||||
page: "superalphas",
|
||||
});
|
||||
const [backtestContext, setBacktestContext] = useState<PageContext>({
|
||||
page: "backtests",
|
||||
});
|
||||
@@ -252,7 +257,23 @@ export default function App() {
|
||||
location.hash = next;
|
||||
setPage(next);
|
||||
};
|
||||
const navigationRequest = useRef(0);
|
||||
const handleAction = (action: UIAction) => {
|
||||
const request = ++navigationRequest.current;
|
||||
if (action.type === "open_alpha" && !action.alpha_type) {
|
||||
void api<{ alpha_type: string }>(
|
||||
`/alphas/${encodeURIComponent(action.alpha_id)}`,
|
||||
)
|
||||
.then((alpha) => {
|
||||
if (navigationRequest.current === request)
|
||||
handleAction({
|
||||
...action,
|
||||
alpha_type: alpha.alpha_type || "REGULAR",
|
||||
});
|
||||
})
|
||||
.catch((error: Error) => Toast.error(error.message));
|
||||
return;
|
||||
}
|
||||
const destination = actionDestination(action);
|
||||
if (!destination) return;
|
||||
if (destination.chat === "open") setChatOpen(true);
|
||||
@@ -261,6 +282,21 @@ export default function App() {
|
||||
if (destination.page) changePage(destination.page);
|
||||
setAIAction(action);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!authenticated) return;
|
||||
const locate = () => {
|
||||
const target = pageFromHash(location.hash);
|
||||
if (!["alphas", "superalphas"].includes(target)) return;
|
||||
const id = new URLSearchParams(location.hash.split("?")[1]).get(
|
||||
"alpha_id",
|
||||
);
|
||||
if (id)
|
||||
handleAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
|
||||
};
|
||||
locate();
|
||||
window.addEventListener("hashchange", locate);
|
||||
return () => window.removeEventListener("hashchange", locate);
|
||||
}, [authenticated]);
|
||||
const logout = async () => {
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
@@ -439,6 +475,44 @@ export default function App() {
|
||||
onOverlay={focusBusiness}
|
||||
/>
|
||||
</div>
|
||||
<div className="alpha-page-view" hidden={page !== "superalphas"}>
|
||||
<AlphaPage
|
||||
managementScope="super"
|
||||
onAction={handleAction}
|
||||
taskPanelOpen={showJobs}
|
||||
account={account}
|
||||
version={`${refreshKey}:${resourceVersions.alphas}:${completedVersion}`}
|
||||
onTask={taskCreated}
|
||||
onAccount={() => changePage("account")}
|
||||
active={page === "superalphas"}
|
||||
overlaySuspended={
|
||||
page !== "superalphas" || (viewport < 1440 && chatOpen)
|
||||
}
|
||||
chatOffset={chatOffset}
|
||||
onContext={setSuperAlphaContext}
|
||||
action={
|
||||
aiAction?.type === "open_alpha" ||
|
||||
aiAction?.type === "apply_filters"
|
||||
? aiAction
|
||||
: null
|
||||
}
|
||||
onOverlay={focusBusiness}
|
||||
/>
|
||||
</div>
|
||||
{visitedResearch.includes("superalpha-research") && (
|
||||
<div
|
||||
className="alpha-page-view"
|
||||
hidden={page !== "superalpha-research"}
|
||||
>
|
||||
<SuperAlphaResearchPage
|
||||
active={page === "superalpha-research"}
|
||||
action={aiAction}
|
||||
onAction={handleAction}
|
||||
onContext={setResearchContext}
|
||||
timezone={account?.timezone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="backtest-page-view" hidden={page !== "backtests"}>
|
||||
{visitedBacktests && (
|
||||
<BacktestPage
|
||||
@@ -572,6 +646,11 @@ export default function App() {
|
||||
? researchContext
|
||||
: { page: "variants" as const },
|
||||
alphas: alphaContext,
|
||||
superalphas: superAlphaContext,
|
||||
"superalpha-research":
|
||||
researchContext.page === "superalpha-research"
|
||||
? researchContext
|
||||
: { page: "superalpha-research" as const },
|
||||
datasets: datasetContext,
|
||||
fields: { page: "fields" as const },
|
||||
preparations: { page: "preparations" as const },
|
||||
|
||||
@@ -15,6 +15,8 @@ export type PageContext = {
|
||||
page:
|
||||
| "home"
|
||||
| "alphas"
|
||||
| "superalphas"
|
||||
| "superalpha-research"
|
||||
| "account"
|
||||
| "datasets"
|
||||
| "fields"
|
||||
@@ -48,9 +50,17 @@ export type PageContext = {
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
export type AlphaUIAction =
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
| { type: "open_alpha"; alpha_id: string; alpha_type?: string; nonce: number }
|
||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||
export type UIAction =
|
||||
| {
|
||||
type: "open_superalpha_research";
|
||||
plan_id?: string;
|
||||
version?: number;
|
||||
experiment_id?: string;
|
||||
alpha_id?: string;
|
||||
nonce: number;
|
||||
}
|
||||
| { type: "open_feature"; asset_id: string; version?: number; nonce: number }
|
||||
| { type: "open_template"; asset_id: string; version?: number; nonce: number }
|
||||
| { type: "open_experiment"; experiment_id: string; nonce: number }
|
||||
@@ -59,7 +69,7 @@ export type UIAction =
|
||||
| { type: "open_research_input"; input_id: string; nonce: number }
|
||||
| { type: "open_backtest"; run_id: string; nonce: number }
|
||||
| { type: "open_backtest_preview"; preview_id: string; nonce: number }
|
||||
| { type: "open_alpha"; alpha_id: string; nonce: number }
|
||||
| { type: "open_alpha"; alpha_id: string; alpha_type?: string; nonce: number }
|
||||
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
|
||||
export type ToolCard = {
|
||||
id: string;
|
||||
|
||||
@@ -68,6 +68,10 @@ const contextLabels: Record<
|
||||
home: () => "上下文:首页看板",
|
||||
alphas: (context) =>
|
||||
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`,
|
||||
superalphas: (c) =>
|
||||
`上下文:Super Alpha 管理${c.alpha_id ? ` · ${c.alpha_id}` : ""}`,
|
||||
"superalpha-research": (c) =>
|
||||
`上下文:Super Alpha 研究${c.research_asset_id ? ` · 方案 ${c.research_asset_id}` : ""}${c.research_experiment_id ? ` · 候选 ${c.research_experiment_id}` : ""}`,
|
||||
operators: () => "上下文:算子库",
|
||||
templates: (context) =>
|
||||
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
|
||||
@@ -100,6 +104,7 @@ type Destination = {
|
||||
};
|
||||
// Exhaustive action destinations prevent a new action silently falling into Alpha.
|
||||
const destinations: Record<UIAction["type"], Destination> = {
|
||||
open_superalpha_research: { page: "superalpha-research", chat: "responsive" },
|
||||
open_feature: { page: "features", chat: "responsive" },
|
||||
open_template: { page: "templates", chat: "responsive" },
|
||||
open_experiment: { page: "templates", chat: "responsive" },
|
||||
@@ -113,6 +118,14 @@ const destinations: Record<UIAction["type"], Destination> = {
|
||||
};
|
||||
|
||||
export function actionDestination(action: UIAction): Destination | undefined {
|
||||
if (action.type === "open_alpha" && action.alpha_type === "SUPER")
|
||||
return { page: "superalphas", chat: "responsive" };
|
||||
if (
|
||||
action.type === "apply_filters" &&
|
||||
(action.filters.management_scope === "super" ||
|
||||
action.filters.alpha_type === "SUPER")
|
||||
)
|
||||
return { page: "superalphas", chat: "responsive" };
|
||||
return Object.hasOwn(destinations, action.type)
|
||||
? destinations[action.type]
|
||||
: undefined;
|
||||
|
||||
@@ -85,6 +85,7 @@ export const jobLabels: Record<string, string> = {
|
||||
catalog_full_sync: "全量同步数据目录与字段",
|
||||
catalog_sync: "同步数据集目录",
|
||||
field_sync: "同步数据字段",
|
||||
super_selection_preview: "Super Alpha 组件预览",
|
||||
full_sync: "全量同步 Alpha",
|
||||
daily_sync: "按天同步 Alpha",
|
||||
self_correlation: "本地自相关检测",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
|
||||
import {
|
||||
CatalogTableToolbar,
|
||||
CatalogIconAction,
|
||||
@@ -90,6 +91,7 @@ export function BacktestPage({
|
||||
}) {
|
||||
const [view, setView] = useState("runs");
|
||||
const [sourceFilter, setSourceFilter] = useState("");
|
||||
const [alphaType, setAlphaType] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
@@ -180,7 +182,9 @@ export function BacktestPage({
|
||||
offset: (page - 1) * pageSize,
|
||||
sort: sort.value,
|
||||
direction: sort.direction,
|
||||
...(view === "runs" ? { source: sourceFilter } : {}),
|
||||
...(view === "runs"
|
||||
? { source: sourceFilter, alpha_type: alphaType }
|
||||
: {}),
|
||||
});
|
||||
const [data, c, kinds] = await Promise.all([
|
||||
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
|
||||
@@ -199,7 +203,16 @@ export function BacktestPage({
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, sourceFilter, q, view, sort.value, sort.direction]);
|
||||
}, [
|
||||
page,
|
||||
pageSize,
|
||||
sourceFilter,
|
||||
alphaType,
|
||||
q,
|
||||
view,
|
||||
sort.value,
|
||||
sort.direction,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let alive = true;
|
||||
@@ -472,6 +485,24 @@ export function BacktestPage({
|
||||
const drawerVisible = active && !suspended;
|
||||
return (
|
||||
<section className="backtest-page">
|
||||
{view === "runs" && (
|
||||
<div className="backtest-toolbar">
|
||||
<span>候选类型</span>
|
||||
<Select
|
||||
aria-label="回测候选类型"
|
||||
value={alphaType}
|
||||
optionList={[
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "REGULAR", label: "REGULAR" },
|
||||
{ value: "SUPER", label: "SUPER" },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
setAlphaType(String(v));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CatalogTableToolbar
|
||||
label="回测研究"
|
||||
active={active && !suspended && !editor && !runId && !settingsDraft}
|
||||
@@ -723,8 +754,21 @@ export function BacktestPage({
|
||||
{
|
||||
title: "表达式",
|
||||
dataIndex: "expression",
|
||||
width: 260,
|
||||
width: 320,
|
||||
ellipsis: true,
|
||||
render: (_, c) => (
|
||||
<span
|
||||
title={
|
||||
c!.alpha_type === "SUPER"
|
||||
? `Selection: ${c!.selection}\nCombo: ${c!.combo}`
|
||||
: c!.expression
|
||||
}
|
||||
>
|
||||
{c!.alpha_type === "SUPER"
|
||||
? `SUPER · Selection: ${c!.selection} · Combo: ${c!.combo}`
|
||||
: c!.expression}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "最终参数",
|
||||
@@ -1002,12 +1046,14 @@ export function BacktestPage({
|
||||
dataIndex: "expression",
|
||||
width: 260,
|
||||
ellipsis: true,
|
||||
render: (v, i) => (
|
||||
render: (_, i) => (
|
||||
<Button
|
||||
theme="borderless"
|
||||
onClick={() => setItemDetail(i!)}
|
||||
>
|
||||
{String(v)}
|
||||
{i!.alpha_type === "SUPER"
|
||||
? `SUPER · ${i!.selection} · ${i!.combo}`
|
||||
: i!.expression}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -1055,6 +1101,7 @@ export function BacktestPage({
|
||||
onAction({
|
||||
type: "open_alpha",
|
||||
alpha_id: i!.alpha_id!,
|
||||
alpha_type: i!.alpha_type,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
@@ -1081,7 +1128,21 @@ export function BacktestPage({
|
||||
收起候选详情
|
||||
</Button>
|
||||
</div>
|
||||
<p>{itemDetail.expression}</p>
|
||||
{itemDetail.alpha_type === "SUPER" ? (
|
||||
<>
|
||||
<h4>Selection</h4>
|
||||
<pre>{itemDetail.selection}</pre>
|
||||
<h4>Combo</h4>
|
||||
<pre>{itemDetail.combo}</pre>
|
||||
<ComponentsPanel
|
||||
key={itemDetail.id}
|
||||
url={`/backtests/items/${itemDetail.id}/artifact?kind=components`}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>{itemDetail.expression}</p>
|
||||
)}
|
||||
{itemDetail.error && (
|
||||
<Banner type="warning" description={itemDetail.error} />
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,9 @@ export type SimulationSettings = {
|
||||
language: "FASTEXPR";
|
||||
visualization: boolean;
|
||||
maxTrade: "ON" | "OFF";
|
||||
selectionHandling?: "POSITIVE" | "NON_ZERO" | "NON_NAN";
|
||||
selectionLimit?: number;
|
||||
componentActivation?: "IS" | "OS";
|
||||
maxPosition?: "ON" | "OFF";
|
||||
};
|
||||
export const initialSettings: SimulationSettings = {
|
||||
@@ -34,9 +37,15 @@ export type Candidate = {
|
||||
client_item_id: string;
|
||||
expression: string;
|
||||
settings: SimulationSettings;
|
||||
alpha_type?: "REGULAR";
|
||||
alpha_type?: "REGULAR" | "SUPER";
|
||||
selection?: string;
|
||||
combo?: string;
|
||||
};
|
||||
export type Source = {
|
||||
research_kind?: string | null;
|
||||
superalpha_plan_id?: string | null;
|
||||
superalpha_plan_version?: number | null;
|
||||
selection_snapshot_ids?: string[];
|
||||
kind: string;
|
||||
reference?: string | null;
|
||||
batch_id?: string | null;
|
||||
@@ -102,6 +111,9 @@ export type Run = {
|
||||
scheduler: Scheduler;
|
||||
};
|
||||
export type Item = {
|
||||
alpha_type: "REGULAR" | "SUPER";
|
||||
selection?: string;
|
||||
combo?: string;
|
||||
id: string;
|
||||
client_item_id: string;
|
||||
expression: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatAlphaMetric } from "../alphaMetrics";
|
||||
import {
|
||||
@@ -65,6 +66,7 @@ export function AlphaDetail({
|
||||
chatOffset?: number;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
|
||||
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||
const [research, setResearch] = useState<Research | null>(null);
|
||||
const [tagText, setTagText] = useState("");
|
||||
@@ -77,6 +79,7 @@ export function AlphaDetail({
|
||||
dirty.current = false;
|
||||
setConflict(false);
|
||||
setDetail(null);
|
||||
setDescriptions({});
|
||||
setResearch(null);
|
||||
setPnl(null);
|
||||
setError("");
|
||||
@@ -122,6 +125,21 @@ export function AlphaDetail({
|
||||
active = false;
|
||||
};
|
||||
}, [id, tab, version]);
|
||||
useEffect(() => {
|
||||
if (!id || detail?.alpha_type !== "SUPER") return;
|
||||
const c = new AbortController();
|
||||
void api<{ descriptions: Record<string, string> }>(
|
||||
`/superalpha/alphas/${id}`,
|
||||
{ signal: c.signal },
|
||||
)
|
||||
.then((value) => {
|
||||
if (!c.signal.aborted) setDescriptions(value.descriptions);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!c.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => c.abort();
|
||||
}, [id, detail?.alpha_type, version]);
|
||||
async function fetchPnl() {
|
||||
try {
|
||||
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
||||
@@ -212,6 +230,11 @@ export function AlphaDetail({
|
||||
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
|
||||
<Tag>{detail.language ?? "语言未提供"}</Tag>
|
||||
<Tag>{detail.status ?? "状态未提供"}</Tag>
|
||||
{detail.check_type === "PPAC_CANDIDATE" && (
|
||||
<Tag color="yellow" size="small" type="light">
|
||||
候选PPAC
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
@@ -222,6 +245,25 @@ export function AlphaDetail({
|
||||
<Button>在 BRAIN 中打开 ↗</Button>
|
||||
</a>
|
||||
</div>
|
||||
{detail.check_type === "PPAC_CANDIDATE" && (
|
||||
<p className="muted">
|
||||
唯一失败项为 PURE_POWER_POOL_THEME,等待平台开放 PPAC
|
||||
主题后可重新检查提交资格。
|
||||
</p>
|
||||
)}
|
||||
{detail.alpha_type === "SUPER" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_superalpha_research",
|
||||
alpha_id: detail.id,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
复制为研究方案
|
||||
</Button>
|
||||
)}
|
||||
<Tabs
|
||||
className="alpha-detail-tabs"
|
||||
activeKey={tab}
|
||||
@@ -256,6 +298,18 @@ export function AlphaDetail({
|
||||
{detail.combo !== null && (
|
||||
<CodeBlock title="Combo" value={detail.combo} />
|
||||
)}
|
||||
{detail.alpha_type === "SUPER" && (
|
||||
<>
|
||||
<CodeBlock
|
||||
title="Selection Description"
|
||||
value={descriptions.selection || "未提供"}
|
||||
/>
|
||||
<CodeBlock
|
||||
title="Combo Description"
|
||||
value={descriptions.combo || "未提供"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<h3>回测设置</h3>
|
||||
<DetailFieldGrid
|
||||
@@ -266,6 +320,17 @@ export function AlphaDetail({
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
{detail.alpha_type === "SUPER" && (
|
||||
<TabPane tab="组件证据" itemKey="components">
|
||||
{tab === "components" && (
|
||||
<ComponentsPanel
|
||||
key={detail.id}
|
||||
url={`/superalpha/alphas/${detail.id}/components`}
|
||||
timezone={timezone}
|
||||
/>
|
||||
)}
|
||||
</TabPane>
|
||||
)}
|
||||
<TabPane tab="指标" itemKey="metrics">
|
||||
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
||||
<Divider />
|
||||
|
||||
@@ -34,10 +34,22 @@ const navigation = [
|
||||
},
|
||||
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
|
||||
{
|
||||
id: "superalpha-research",
|
||||
label: "Super Alpha 研究",
|
||||
icon: IconBeaker,
|
||||
group: "研究实验",
|
||||
},
|
||||
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
|
||||
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
|
||||
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
|
||||
{
|
||||
id: "superalphas",
|
||||
label: "Super Alpha 管理",
|
||||
icon: IconGridView,
|
||||
group: "研究成果",
|
||||
},
|
||||
{ id: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
|
||||
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
|
||||
] as const;
|
||||
@@ -285,7 +297,7 @@ export function AppSidebar({
|
||||
>
|
||||
<div className="command-items">
|
||||
{navigation.map(({ id, label, icon: Icon }) => (
|
||||
<button key={id} onClick={() => navigate(id)}>
|
||||
<button key={id} aria-label={label} onClick={() => navigate(id)}>
|
||||
<Icon />
|
||||
{label}
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Popover,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
TextArea,
|
||||
Toast,
|
||||
@@ -99,6 +99,7 @@ const checkLabels = {
|
||||
PENDING: "待检查",
|
||||
PRE_CHECK: "预检通过",
|
||||
PASS: "检查通过",
|
||||
PPAC_CANDIDATE: "候选PPAC",
|
||||
FAIL_1: "FAIL=1",
|
||||
FAIL_2: "FAIL≥2",
|
||||
};
|
||||
@@ -118,6 +119,9 @@ const initialColumns = [
|
||||
"source",
|
||||
];
|
||||
const columnLabels: Record<string, string> = {
|
||||
selection: "Selection",
|
||||
combo: "Combo",
|
||||
component_count: "组件数(已核实)",
|
||||
source: "研究来源",
|
||||
name: "Alpha",
|
||||
expression: "表达式",
|
||||
@@ -147,6 +151,7 @@ const columnLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function AlphaPage({
|
||||
managementScope = "non_super",
|
||||
account,
|
||||
taskPanelOpen,
|
||||
version,
|
||||
@@ -160,6 +165,7 @@ export function AlphaPage({
|
||||
onOverlay,
|
||||
onAction,
|
||||
}: {
|
||||
managementScope?: "super" | "non_super";
|
||||
account: Account | null;
|
||||
taskPanelOpen: boolean;
|
||||
version: string;
|
||||
@@ -173,6 +179,21 @@ export function AlphaPage({
|
||||
onOverlay: () => void;
|
||||
onAction: (action: WorkspaceAction) => void;
|
||||
}) {
|
||||
const columnKey =
|
||||
managementScope === "super" ? "superalpha-columns" : "alpha-columns";
|
||||
const defaultColumns =
|
||||
managementScope === "super"
|
||||
? initialColumns.flatMap((c) =>
|
||||
c === "expression" ? ["selection", "combo", "component_count"] : [c],
|
||||
)
|
||||
: initialColumns;
|
||||
const menuColumns = Object.fromEntries(
|
||||
Object.entries(columnLabels).filter(([k]) =>
|
||||
managementScope === "super"
|
||||
? k !== "expression"
|
||||
: !["selection", "combo", "component_count"].includes(k),
|
||||
),
|
||||
);
|
||||
const [data, setData] = useState<Page>({
|
||||
items: [],
|
||||
total: 0,
|
||||
@@ -185,6 +206,7 @@ export function AlphaPage({
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
|
||||
const [submissionBlocked, setSubmissionBlocked] = useState(false);
|
||||
const [ppacCandidate, setPpacCandidate] = useState(false);
|
||||
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
|
||||
const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -197,7 +219,7 @@ export function AlphaPage({
|
||||
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(
|
||||
localStorage.getItem("alpha-columns") || "null",
|
||||
localStorage.getItem(columnKey) || "null",
|
||||
);
|
||||
return Array.isArray(stored)
|
||||
? [
|
||||
@@ -208,9 +230,9 @@ export function AlphaPage({
|
||||
),
|
||||
]),
|
||||
]
|
||||
: initialColumns;
|
||||
: defaultColumns;
|
||||
} catch {
|
||||
return initialColumns;
|
||||
return defaultColumns;
|
||||
}
|
||||
});
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
@@ -223,6 +245,7 @@ export function AlphaPage({
|
||||
version: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const selectionVersions = useRef<Record<string, number>>({});
|
||||
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
|
||||
const actionNonce = useRef(0);
|
||||
const [bulk, setBulk] = useState({
|
||||
@@ -250,22 +273,34 @@ export function AlphaPage({
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
...filters,
|
||||
management_scope: managementScope,
|
||||
submission,
|
||||
submission_blocked: submissionBlocked || undefined,
|
||||
ppac_candidate: ppacCandidate || undefined,
|
||||
sort,
|
||||
direction,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
}),
|
||||
[filters, submission, submissionBlocked, sort, direction, pageSize, page],
|
||||
[
|
||||
filters,
|
||||
submission,
|
||||
submissionBlocked,
|
||||
ppacCandidate,
|
||||
sort,
|
||||
direction,
|
||||
pageSize,
|
||||
page,
|
||||
managementScope,
|
||||
],
|
||||
);
|
||||
const query = queryString(params);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
onContext({
|
||||
page: "alphas",
|
||||
page: managementScope === "super" ? "superalphas" : "alphas",
|
||||
alpha_id: detailId,
|
||||
selected_ids: selected,
|
||||
selected_ids: selected.slice(0, 100),
|
||||
filters: Object.fromEntries(
|
||||
Object.entries(params).filter(
|
||||
([, value]) => value !== "" && value !== undefined && value !== null,
|
||||
@@ -274,7 +309,7 @@ export function AlphaPage({
|
||||
});
|
||||
}, [active, detailId, selected, params, onContext]);
|
||||
useEffect(() => {
|
||||
if (!action || actionNonce.current === action.nonce) return;
|
||||
if (!active || !action || actionNonce.current === action.nonce) return;
|
||||
actionNonce.current = action.nonce;
|
||||
if (action.type === "open_alpha") {
|
||||
setDetailId(action.alpha_id);
|
||||
@@ -289,6 +324,7 @@ export function AlphaPage({
|
||||
offset: _offset,
|
||||
submission: nextSubmission,
|
||||
submission_blocked: nextBlocked,
|
||||
ppac_candidate: nextPpacCandidate,
|
||||
...values
|
||||
} = action.filters;
|
||||
const next = Object.fromEntries(
|
||||
@@ -303,10 +339,13 @@ export function AlphaPage({
|
||||
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
|
||||
setSubmission(nextSubmission);
|
||||
setSubmissionBlocked(nextBlocked === true || nextBlocked === "true");
|
||||
setPpacCandidate(
|
||||
nextPpacCandidate === true || nextPpacCandidate === "true",
|
||||
);
|
||||
if (nextSort) setSort(String(nextSort));
|
||||
if (nextDirection) setDirection(String(nextDirection));
|
||||
}
|
||||
}, [action, onOverlay]);
|
||||
}, [action, onOverlay, active]);
|
||||
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -315,7 +354,9 @@ export function AlphaPage({
|
||||
setError("");
|
||||
Promise.all([
|
||||
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
|
||||
api<Facets>("/alphas/facets", { signal: controller.signal }),
|
||||
api<Facets>(`/alphas/facets?management_scope=${managementScope}`, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
])
|
||||
.then(([results, nextFacets]) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -333,6 +374,9 @@ export function AlphaPage({
|
||||
}, [active, query, version, localVersion]);
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
selectionVersions.current = {};
|
||||
}, [filters, submission, submissionBlocked, ppacCandidate, managementScope]);
|
||||
useEffect(() => {
|
||||
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
||||
}, [query]);
|
||||
function updateDraft(key: string, value: unknown) {
|
||||
@@ -358,6 +402,7 @@ export function AlphaPage({
|
||||
setDirection("desc");
|
||||
setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED");
|
||||
setSubmissionBlocked(value === "SUBMISSION_BLOCKED");
|
||||
setPpacCandidate(value === "PPAC_CANDIDATE");
|
||||
setDraft({});
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
@@ -464,6 +509,24 @@ export function AlphaPage({
|
||||
</code>
|
||||
),
|
||||
},
|
||||
...(["selection", "combo"] as const).map(
|
||||
(key): ColumnProps<Alpha> => ({
|
||||
key,
|
||||
title: columnLabels[key],
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<code title={row![`${key}_preview`]}>
|
||||
{row![`${key}_preview`] || "未提供"}
|
||||
</code>
|
||||
),
|
||||
}),
|
||||
),
|
||||
{
|
||||
key: "component_count",
|
||||
title: "组件数(已核实)",
|
||||
width: 140,
|
||||
render: (_, row) => row!.component_count ?? "未核实",
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
title: "研究来源",
|
||||
@@ -528,14 +591,18 @@ export function AlphaPage({
|
||||
width: 125,
|
||||
render: (_, row) => (
|
||||
<Tag
|
||||
size="small"
|
||||
type="light"
|
||||
color={
|
||||
row!.check_type?.startsWith("FAIL")
|
||||
? "red"
|
||||
: row!.check_type === "PASS"
|
||||
? "green"
|
||||
: row!.check_type === "PRE_CHECK"
|
||||
? "blue"
|
||||
: "grey"
|
||||
row!.check_type === "PPAC_CANDIDATE"
|
||||
? "yellow"
|
||||
: row!.check_type?.startsWith("FAIL")
|
||||
? "red"
|
||||
: row!.check_type === "PASS"
|
||||
? "green"
|
||||
: row!.check_type === "PRE_CHECK"
|
||||
? "blue"
|
||||
: "grey"
|
||||
}
|
||||
>
|
||||
{checkLabels[row!.check_type] || "待检查"}
|
||||
@@ -953,8 +1020,10 @@ export function AlphaPage({
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
<section className="library-panel" ref={tablePanel}>
|
||||
<SavedViews
|
||||
managementScope={managementScope}
|
||||
submission={submission}
|
||||
submissionBlocked={submissionBlocked}
|
||||
ppacCandidate={ppacCandidate}
|
||||
selectedId={selectedViewId}
|
||||
onSelect={setSelectedViewId}
|
||||
onSubmissionChange={changeSubmission}
|
||||
@@ -964,6 +1033,7 @@ export function AlphaPage({
|
||||
...filters,
|
||||
submission,
|
||||
submission_blocked: submissionBlocked || undefined,
|
||||
ppac_candidate: ppacCandidate || undefined,
|
||||
sort,
|
||||
direction,
|
||||
limit: pageSize,
|
||||
@@ -974,6 +1044,7 @@ export function AlphaPage({
|
||||
const {
|
||||
submission: savedSubmission,
|
||||
submission_blocked: savedBlocked,
|
||||
ppac_candidate: savedPpacCandidate,
|
||||
sort: savedSort,
|
||||
direction: savedDirection,
|
||||
limit,
|
||||
@@ -995,6 +1066,9 @@ export function AlphaPage({
|
||||
setSubmissionBlocked(
|
||||
savedBlocked === true || savedBlocked === "true",
|
||||
);
|
||||
setPpacCandidate(
|
||||
savedPpacCandidate === true || savedPpacCandidate === "true",
|
||||
);
|
||||
setSort(String(savedSort || "date_created"));
|
||||
setDirection(String(savedDirection || "desc"));
|
||||
if (typeof limit === "number") setPageSize(limit);
|
||||
@@ -1005,7 +1079,7 @@ export function AlphaPage({
|
||||
]),
|
||||
];
|
||||
setVisibleColumns(next);
|
||||
localStorage.setItem("alpha-columns", JSON.stringify(next));
|
||||
localStorage.setItem(columnKey, JSON.stringify(next));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -1058,7 +1132,7 @@ export function AlphaPage({
|
||||
<CheckboxGroup
|
||||
direction="vertical"
|
||||
value={displayedColumns}
|
||||
options={Object.entries(columnLabels).map(
|
||||
options={Object.entries(menuColumns).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
@@ -1071,10 +1145,7 @@ export function AlphaPage({
|
||||
onChange={(values) => {
|
||||
const next = values.map(String);
|
||||
setVisibleColumns(next);
|
||||
localStorage.setItem(
|
||||
"alpha-columns",
|
||||
JSON.stringify(next),
|
||||
);
|
||||
localStorage.setItem(columnKey, JSON.stringify(next));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1221,9 +1292,7 @@ export function AlphaPage({
|
||||
onClick={() => {
|
||||
setBulkVersions(
|
||||
Object.fromEntries(
|
||||
data.items
|
||||
.filter((row) => selected.includes(row.id))
|
||||
.map((row) => [row.id, row.research.version]),
|
||||
selected.map((id) => [id, selectionVersions.current[id]]),
|
||||
),
|
||||
);
|
||||
setBulkOpen(true);
|
||||
@@ -1252,7 +1321,8 @@ export function AlphaPage({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Table<Alpha>
|
||||
<WorkspaceTable<Alpha>
|
||||
fill
|
||||
columns={columns}
|
||||
dataSource={data.items}
|
||||
rowKey="id"
|
||||
@@ -1265,7 +1335,19 @@ export function AlphaPage({
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected((keys ?? []).map(String)),
|
||||
onChange: (keys) => {
|
||||
const pageIds = new Set(data.items.map((r) => r.id));
|
||||
const nextIds = (keys ?? [])
|
||||
.map(String)
|
||||
.filter((id) => pageIds.has(id));
|
||||
for (const row of data.items)
|
||||
if (nextIds.includes(row.id))
|
||||
selectionVersions.current[row.id] = row.research.version;
|
||||
setSelected((old) => [
|
||||
...old.filter((id) => !pageIds.has(id)),
|
||||
...nextIds,
|
||||
]);
|
||||
},
|
||||
fixed: !compact,
|
||||
}}
|
||||
pagination={false}
|
||||
@@ -1273,14 +1355,18 @@ export function AlphaPage({
|
||||
<Empty
|
||||
className="alpha-empty"
|
||||
title={
|
||||
activeFilterCount
|
||||
? "没有符合条件的 Alpha"
|
||||
: "从第一个 Alpha 开始"
|
||||
ppacCandidate
|
||||
? "没有符合条件的候选PPAC"
|
||||
: activeFilterCount
|
||||
? "没有符合条件的 Alpha"
|
||||
: "从第一个 Alpha 开始"
|
||||
}
|
||||
description={
|
||||
activeFilterCount
|
||||
? "调整筛选条件,探索更多信号。"
|
||||
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
||||
ppacCandidate
|
||||
? "仅收录未提交且唯一失败项为 PURE_POWER_POOL_THEME 的 Alpha,等待平台开放 PPAC 主题。"
|
||||
: activeFilterCount
|
||||
? "调整筛选条件,探索更多信号。"
|
||||
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
||||
}
|
||||
/>
|
||||
}
|
||||
@@ -1288,11 +1374,13 @@ export function AlphaPage({
|
||||
<footer className="table-pagination" aria-label="Alpha 分页">
|
||||
<div className="alpha-table-meta">
|
||||
<span className="muted">
|
||||
{submissionBlocked
|
||||
? "提交受阻"
|
||||
: submission === "UNSUBMITTED"
|
||||
? "待提交"
|
||||
: "已提交"}{" "}
|
||||
{ppacCandidate
|
||||
? "候选PPAC"
|
||||
: submissionBlocked
|
||||
? "提交受阻"
|
||||
: submission === "UNSUBMITTED"
|
||||
? "待提交"
|
||||
: "已提交"}{" "}
|
||||
Alpha{" "}
|
||||
<span className="count-pill">{data.total.toLocaleString()}</span>{" "}
|
||||
条
|
||||
|
||||
@@ -25,12 +25,12 @@ const scopes = [
|
||||
[
|
||||
"research:refresh",
|
||||
"刷新研究数据",
|
||||
"更新缓存、检查自相关及恢复 WorldQuant 认证",
|
||||
"更新缓存、预览 SUPER 组件、检查自相关及恢复 WorldQuant 认证",
|
||||
],
|
||||
[
|
||||
"research:write",
|
||||
"保存研究模板",
|
||||
"保存大模型总结的模板及来源,供后续批量回测",
|
||||
"保存研究模板与方案",
|
||||
"保存模板、Super Alpha 方案及构造候选,不执行回测",
|
||||
],
|
||||
["backtests:execute", "执行回测", "提交新的回测批次"],
|
||||
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "@douyinfe/semi-ui-19";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import type { ResearchCandidate } from "./workspaceTypes";
|
||||
|
||||
/** Selection spans pages; eligibility is supplied by the producing workflow. */
|
||||
export function CandidateTable({
|
||||
candidates,
|
||||
selected,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
requireValidation = false,
|
||||
}: {
|
||||
candidates: ResearchCandidate[];
|
||||
selected: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
requireValidation?: boolean;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
return (
|
||||
<>
|
||||
<WorkspaceTable<ResearchCandidate>
|
||||
dataSource={candidates.slice((page - 1) * pageSize, page * pageSize)}
|
||||
className="research-candidate-table"
|
||||
rowKey="client_item_id"
|
||||
scroll={{ x: 1120, y: 400 }}
|
||||
empty="暂无候选表达式。"
|
||||
rowSelection={{
|
||||
width: 48,
|
||||
disabled,
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => onSelectionChange((keys ?? []).map(String)),
|
||||
getCheckboxProps: (candidate) => ({
|
||||
disabled:
|
||||
disabled ||
|
||||
(requireValidation && candidate.validation?.status !== "valid"),
|
||||
"aria-label": `选择候选 ${candidate.client_item_id}`,
|
||||
title: requireValidation
|
||||
? [
|
||||
...(candidate.validation?.syntax ?? []),
|
||||
...(candidate.validation?.types ?? []),
|
||||
...(candidate.validation?.availability ?? []),
|
||||
].join(";")
|
||||
: undefined,
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "表达式",
|
||||
dataIndex: "expression",
|
||||
width: 420,
|
||||
render: (value) => <code title={value}>{value}</code>,
|
||||
},
|
||||
...(
|
||||
[
|
||||
["neutralization", "Neutralization", 170],
|
||||
["decay", "Decay", 90],
|
||||
["truncation", "Truncation", 120],
|
||||
["maxTrade", "Max Trade", 120],
|
||||
["nanHandling", "NaN Handling", 150],
|
||||
] as const
|
||||
).map(([key, title, width]) => ({
|
||||
key,
|
||||
title,
|
||||
width,
|
||||
render: (_: unknown, candidate: ResearchCandidate) =>
|
||||
candidate.settings[key],
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="table-pagination workspace-table-footer">
|
||||
<span>
|
||||
共 {candidates.length} 个候选 · 已选 {selected.length} 个
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
total={candidates.length}
|
||||
showSizeChanger
|
||||
pageSizeOpts={[25, 50, 100]}
|
||||
preventPageChangeOnPageSizeChange
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Table,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post, formatTime } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
import { EvaluationPanel } from "./EvaluationPanel";
|
||||
import { LineagePanel } from "./LineagePanel";
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { validationLabel } from "./workspaceTypes";
|
||||
import { CandidateTable } from "./CandidateTable";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
|
||||
export function ExperimentView({
|
||||
@@ -33,7 +26,7 @@ export function ExperimentView({
|
||||
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const valid = experiment.candidates.filter(
|
||||
(c) => c.validation.status === "valid",
|
||||
(c) => c.validation?.status === "valid",
|
||||
);
|
||||
async function preview() {
|
||||
setBusy(true);
|
||||
@@ -72,7 +65,10 @@ export function ExperimentView({
|
||||
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
<details>
|
||||
<summary>研究描述</summary>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
</details>
|
||||
{experiment.archived ? (
|
||||
<Banner
|
||||
type="info"
|
||||
@@ -92,175 +88,12 @@ export function ExperimentView({
|
||||
type="info"
|
||||
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
|
||||
/>
|
||||
<div className="research-table-scroll">
|
||||
<Table<Experiment["candidates"][number]>
|
||||
className="research-table"
|
||||
dataSource={experiment.candidates}
|
||||
rowKey="client_item_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
style={{ minWidth: 840 }}
|
||||
empty="暂无候选表达式。"
|
||||
columns={[
|
||||
{
|
||||
title: "选择",
|
||||
key: "selection",
|
||||
width: 64,
|
||||
render: (_, candidate) => (
|
||||
<Checkbox
|
||||
aria-label={`选择候选 ${candidate.client_item_id}`}
|
||||
disabled={candidate.validation.status !== "valid"}
|
||||
checked={selected.includes(candidate.client_item_id)}
|
||||
onChange={(event) =>
|
||||
setSelected((old) =>
|
||||
event.target.checked
|
||||
? [...old, candidate.client_item_id]
|
||||
: old.filter((id) => id !== candidate.client_item_id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "候选表达式",
|
||||
key: "expression",
|
||||
render: (_, candidate) => (
|
||||
<>
|
||||
<code>{candidate.expression}</code>
|
||||
<details>
|
||||
<summary>绑定与改动</summary>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
bindings: candidate.bindings,
|
||||
changes: candidate.changes,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "市场与设置",
|
||||
key: "settings",
|
||||
width: 240,
|
||||
render: (_, candidate) => (
|
||||
<>
|
||||
{candidate.settings.region} / {candidate.settings.universe} /
|
||||
D{candidate.settings.delay}
|
||||
<br />
|
||||
{candidate.settings.neutralization} · decay{" "}
|
||||
{candidate.settings.decay}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "校验",
|
||||
key: "validation",
|
||||
width: 180,
|
||||
render: (_, candidate) => (
|
||||
<>
|
||||
<Tag
|
||||
color={
|
||||
candidate.validation.status === "valid"
|
||||
? "green"
|
||||
: "orange"
|
||||
}
|
||||
>
|
||||
{validationLabel[candidate.validation.status]}
|
||||
</Tag>
|
||||
{(["syntax", "types", "availability"] as const).map((key) =>
|
||||
candidate.validation[key].map((issue, index) => (
|
||||
<p key={`${key}-${index}`}>
|
||||
{
|
||||
{
|
||||
syntax: "语法",
|
||||
types: "类型",
|
||||
availability: "可用性",
|
||||
}[key]
|
||||
}
|
||||
:{issue}
|
||||
</p>
|
||||
)),
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="research-lineage">
|
||||
<strong>研究来源</strong>
|
||||
<span>实验 {experiment.id}</span>
|
||||
{experiment.evidence.template?.id && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_template",
|
||||
asset_id: experiment.evidence.template!.id,
|
||||
version: experiment.evidence.template!.version,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
模板 v{experiment.evidence.template.version}
|
||||
</Button>
|
||||
)}
|
||||
{experiment.inputs.map((input) => (
|
||||
<Button
|
||||
key={input.id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_research_input",
|
||||
input_id: input.id,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{input.name} · {input.scope.region}/{input.scope.universe}/D
|
||||
{input.scope.delay}
|
||||
</Button>
|
||||
))}
|
||||
{experiment.parents.map((parent) => (
|
||||
<Button
|
||||
key={`${parent.kind}:${parent.id}`}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction(
|
||||
parent.kind === "alpha"
|
||||
? {
|
||||
type: "open_alpha",
|
||||
alpha_id: parent.id,
|
||||
nonce: Date.now(),
|
||||
}
|
||||
: {
|
||||
type: "open_experiment",
|
||||
experiment_id: parent.id,
|
||||
nonce: Date.now(),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
父来源:{parent.id}
|
||||
</Button>
|
||||
))}
|
||||
{experiment.backtest_run_ids.map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
|
||||
}
|
||||
>
|
||||
查看关联回测
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<CandidateTable
|
||||
candidates={experiment.candidates}
|
||||
selected={selected}
|
||||
onSelectionChange={setSelected}
|
||||
requireValidation
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
|
||||
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Tabs,
|
||||
TextArea,
|
||||
Toast,
|
||||
Typography,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import { ResearchInput } from "../ai/ResearchInput";
|
||||
import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
|
||||
import { api, post, formatTime } from "../api";
|
||||
@@ -30,17 +33,11 @@ import {
|
||||
} from "./workspaceTypes";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { ExperimentView } from "./ExperimentView";
|
||||
import { TemplateCandidateSet } from "./TemplateCandidateSet";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
import { ComparisonPanel } from "./ComparisonPanel";
|
||||
import "./workspace.css";
|
||||
|
||||
type ImportResult = {
|
||||
templates: Template[];
|
||||
digest: string;
|
||||
conflicts: unknown[];
|
||||
errors: unknown[];
|
||||
differences?: unknown[];
|
||||
};
|
||||
export function ResearchWorkspace({
|
||||
page,
|
||||
active,
|
||||
@@ -54,6 +51,7 @@ export function ResearchWorkspace({
|
||||
onAction: (action: UIAction) => void;
|
||||
onContext: (context: PageContext) => void;
|
||||
}) {
|
||||
const [detailTab, setDetailTab] = useState("editor");
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
|
||||
"editor",
|
||||
@@ -86,11 +84,27 @@ export function ResearchWorkspace({
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const refreshSequence = useRef(0);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const CandidateView =
|
||||
experiment?.kind === "template" ? TemplateCandidateSet : ExperimentView;
|
||||
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
|
||||
const dirty =
|
||||
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
|
||||
const generationKey = JSON.stringify([
|
||||
asset?.id,
|
||||
asset?.version,
|
||||
template,
|
||||
inputIds,
|
||||
inputs,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
]);
|
||||
const currentGeneration = useRef(generationKey);
|
||||
currentGeneration.current = generationKey;
|
||||
useEffect(() => {
|
||||
if (page === "templates" && detailMode === "editor") setExperiment(null);
|
||||
}, [page, template, inputIds, inputs, settings, mode, limit, seed]);
|
||||
async function task(label: string, action: () => Promise<void>) {
|
||||
setBusy(label);
|
||||
setError("");
|
||||
@@ -104,13 +118,12 @@ export function ResearchWorkspace({
|
||||
}
|
||||
async function refresh() {
|
||||
const sequence = ++refreshSequence.current;
|
||||
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
|
||||
const [nextAssets, nextHistory] = await Promise.all([
|
||||
api<{ items: Asset[]; total: number }>(
|
||||
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
|
||||
),
|
||||
Promise.resolve({ items: inputs }),
|
||||
api<{ items: typeof history; total: number }>(
|
||||
`/research/experiments?limit=25&offset=${historyPage * 25}`,
|
||||
`/research/experiments?kind=${page === "templates" ? "template" : "variant"}&limit=25&offset=${historyPage * 25}`,
|
||||
),
|
||||
]);
|
||||
if (sequence !== refreshSequence.current) return;
|
||||
@@ -123,7 +136,6 @@ export function ResearchWorkspace({
|
||||
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
|
||||
);
|
||||
setAssets(nextAssets.items);
|
||||
setInputs(nextInputs.items);
|
||||
setHistory(nextHistory.items);
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -150,6 +162,7 @@ export function ResearchWorkspace({
|
||||
["open_experiment", "open_template", "open_variant"].includes(action.type)
|
||||
) {
|
||||
setDetailOpen(true);
|
||||
setDetailTab("editor");
|
||||
setDetailMode(
|
||||
action.type === "open_experiment" ? "experiment" : "editor",
|
||||
);
|
||||
@@ -165,32 +178,34 @@ export function ResearchWorkspace({
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
const feature = next.provenance?.feature;
|
||||
if (feature) {
|
||||
setInputIds(feature.content.input_ids);
|
||||
setHypothesis(feature.content.hypothesis);
|
||||
const fixed = feature.provenance?.inputs || [];
|
||||
setInputs((old) => [
|
||||
...old,
|
||||
...fixed.filter((i) => !old.some((o) => o.id === i.id)),
|
||||
]);
|
||||
const first = fixed[0];
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}
|
||||
openTemplate(next);
|
||||
});
|
||||
if (action.type === "open_variant") {
|
||||
setParent(action.alpha_id);
|
||||
setMethod("structure");
|
||||
}
|
||||
}, [active, action]);
|
||||
function openTemplate(next: Asset) {
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
setExperiment(null);
|
||||
const feature = next.provenance?.feature;
|
||||
const fixed = feature?.provenance?.inputs || [];
|
||||
setInputIds(feature?.content.input_ids || []);
|
||||
setInputs(fixed);
|
||||
setHypothesis(feature?.content.hypothesis || "");
|
||||
const first = fixed[0];
|
||||
setSettings({
|
||||
...initialSettings,
|
||||
...(first
|
||||
? {
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
async function save() {
|
||||
const next = asset
|
||||
? await api<Asset>(`/research/assets/${asset.id}`, {
|
||||
@@ -253,14 +268,21 @@ export function ResearchWorkspace({
|
||||
asset_id: asset!.id,
|
||||
version: asset!.version,
|
||||
...researchSelection(inputIds, inputs),
|
||||
hypothesis,
|
||||
hypothesis:
|
||||
page === "templates"
|
||||
? template.description.trim() || `使用模板:${template.name}`
|
||||
: hypothesis,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
});
|
||||
if (page === "templates" && currentGeneration.current !== generationKey) {
|
||||
throw new Error("回测准备已改变,请按当前配置重新生成候选集合");
|
||||
}
|
||||
setExperiment(next);
|
||||
setDetailTab("prepare");
|
||||
await refresh();
|
||||
}
|
||||
const combination = Object.values(template.variables)
|
||||
@@ -308,6 +330,11 @@ export function ResearchWorkspace({
|
||||
setAsset(null);
|
||||
setTemplate(blankTemplate());
|
||||
setExperiment(null);
|
||||
setInputIds([]);
|
||||
setInputs([]);
|
||||
setHypothesis("");
|
||||
setSettings(initialSettings);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
@@ -354,20 +381,20 @@ export function ResearchWorkspace({
|
||||
}
|
||||
>
|
||||
{listMode === "assets" ? (
|
||||
<>
|
||||
<Table<Asset>
|
||||
className="research-table"
|
||||
<div className="research-template-table-container">
|
||||
<WorkspaceTable<Asset>
|
||||
key={JSON.stringify([search, assetPage])}
|
||||
fill
|
||||
dataSource={assets}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
sticky
|
||||
style={{ minWidth: 720 }}
|
||||
empty="还没有模板。新建或导入一个模板开始研究。"
|
||||
scroll={{ x: 800, y: "100%" }}
|
||||
empty="还没有模板。新建一个模板开始研究。"
|
||||
columns={[
|
||||
{
|
||||
title: "模板名称",
|
||||
key: "name",
|
||||
width: 280,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_, item) => (
|
||||
<Button
|
||||
theme="borderless"
|
||||
@@ -376,15 +403,20 @@ export function ResearchWorkspace({
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${item.id}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
setExperiment(null);
|
||||
openTemplate(next);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
<Typography.Text
|
||||
size="inherit"
|
||||
style={{ width: "100%", color: "inherit" }}
|
||||
ellipsis={{ showTooltip: true }}
|
||||
>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -397,8 +429,17 @@ export function ResearchWorkspace({
|
||||
{
|
||||
title: "表达式",
|
||||
key: "expression",
|
||||
width: 260,
|
||||
render: (_, item) => <code>{item.content.expression}</code>,
|
||||
width: undefined,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_, item) => (
|
||||
<Typography.Text
|
||||
className="research-template-expression"
|
||||
style={{ width: "100%" }}
|
||||
ellipsis={{ showTooltip: true }}
|
||||
>
|
||||
{item.content.expression}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
@@ -412,19 +453,21 @@ export function ResearchWorkspace({
|
||||
key: "actions",
|
||||
width: 80,
|
||||
render: (_, item) => (
|
||||
<DeleteResearchButton
|
||||
name={item.name}
|
||||
label="模板"
|
||||
disabled={!!busy}
|
||||
onConfirm={() =>
|
||||
task("删除模板", () => removeAsset(item))
|
||||
}
|
||||
/>
|
||||
<div className="workspace-cell-actions">
|
||||
<DeleteResearchButton
|
||||
name={item.name}
|
||||
label="模板"
|
||||
disabled={!!busy}
|
||||
onConfirm={() =>
|
||||
task("删除模板", () => removeAsset(item))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table<(typeof history)[number]>
|
||||
@@ -504,7 +547,7 @@ export function ResearchWorkspace({
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{detailMode === "experiment" ? (
|
||||
experiment && (
|
||||
<ExperimentView
|
||||
<CandidateView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
@@ -519,250 +562,350 @@ export function ResearchWorkspace({
|
||||
{asset?.archived && (
|
||||
<Banner
|
||||
type="info"
|
||||
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。"
|
||||
description="此模板已删除,当前仅显示保留的历史版本。"
|
||||
/>
|
||||
)}
|
||||
<section className="research-card">
|
||||
<h3>研究输入与假设</h3>
|
||||
{page === "variants" && (
|
||||
<>
|
||||
<div className="research-methods">
|
||||
<Button
|
||||
theme={method === "structure" ? "solid" : "light"}
|
||||
onClick={() => setMethod("structure")}
|
||||
>
|
||||
结构与字段变体
|
||||
</Button>
|
||||
<Button
|
||||
theme={method === "settings" ? "solid" : "light"}
|
||||
onClick={() => setMethod("settings")}
|
||||
>
|
||||
市场与设置变体
|
||||
</Button>
|
||||
</div>
|
||||
<p>
|
||||
{method === "structure"
|
||||
? "神笔马良:保留种子与改动依据,研究新的表达式。"
|
||||
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
|
||||
</p>
|
||||
<label>
|
||||
种子 Alpha
|
||||
<Input
|
||||
aria-label="种子 Alpha"
|
||||
value={parent}
|
||||
onChange={setParent}
|
||||
placeholder="已同步的 Alpha ID"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
固定研究输入
|
||||
<ResearchDataInput
|
||||
label="选择数据准备"
|
||||
ids={inputIds}
|
||||
inputs={inputs}
|
||||
onChange={(ids, rows) => {
|
||||
setInputs(rows);
|
||||
setInputIds(ids);
|
||||
const first = rows.find((r) => r.id === ids[0]);
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">
|
||||
从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
|
||||
</p>
|
||||
{page === "variants" && method === "settings" ? (
|
||||
<label>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="research-ai-section">
|
||||
<h4>
|
||||
<IconAIEditLevel3 aria-hidden="true" />
|
||||
本次研究假设
|
||||
</h4>
|
||||
<ResearchInput
|
||||
maxLength={10000}
|
||||
label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
placeholder={
|
||||
page === "variants"
|
||||
? "描述希望保留的信号与要探索的变体方向…"
|
||||
: "描述经济假设、信号方向与希望验证的关系…"
|
||||
}
|
||||
references={[
|
||||
...selectedInputs.map((input) => ({
|
||||
id: input.id,
|
||||
name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
|
||||
})),
|
||||
...(page === "variants" && parent
|
||||
? [
|
||||
{
|
||||
id: "parent-alpha",
|
||||
name: `种子 Alpha:${parent}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
disabled={!!busy}
|
||||
canSubmit={!!inputIds.length}
|
||||
busy={busy === "AI 生成"}
|
||||
submitLabel="AI 生成模板"
|
||||
sendHotKey="shift+enter"
|
||||
onSubmit={() => void task("AI 生成", generate)}
|
||||
prompts={
|
||||
page === "variants"
|
||||
? ["保留种子的经济含义,探索不同时间窗口和字段组合"]
|
||||
: ["研究短期价格反转与成交量之间的关系"]
|
||||
}
|
||||
hint={
|
||||
inputIds.length
|
||||
? "基于已关联的固定输入生成,保留假设供下一轮调整。"
|
||||
: "请先关联固定研究输入。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{page !== "variants" || method === "structure" ? (
|
||||
<section className="research-card">
|
||||
<div className="research-section-heading">
|
||||
<h3>
|
||||
编辑与展开{asset && ` · v${asset.version}`}
|
||||
{dirty && " · 未保存"}
|
||||
</h3>
|
||||
</div>
|
||||
<TemplateEditor
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
/>
|
||||
{asset && (
|
||||
<div className="research-toolbar">
|
||||
<span>版本记录</span>
|
||||
<ResearchSelect
|
||||
label="模板历史版本"
|
||||
value={asset.version}
|
||||
optionList={Array.from(
|
||||
{
|
||||
length:
|
||||
assets.find((item) => item.id === asset.id)
|
||||
?.version || asset.version,
|
||||
},
|
||||
(_, i) => ({ value: i + 1, label: `v${i + 1}` }),
|
||||
)}
|
||||
onChange={(version) =>
|
||||
void task("读取历史版本", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${asset.id}?version=${version}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate({
|
||||
...template,
|
||||
name: `${template.name}(副本)`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
另存新模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
disabled={!!busy || asset?.archived}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存模板版本
|
||||
</Button>
|
||||
{asset && !asset.archived && (
|
||||
<DeleteResearchButton
|
||||
name={asset.name}
|
||||
label="模板"
|
||||
disabled={!!busy}
|
||||
onConfirm={() =>
|
||||
task("删除模板", () => removeAsset(asset))
|
||||
}
|
||||
/>
|
||||
{page === "templates" && (
|
||||
<Tabs
|
||||
type="card"
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
tabPaneMotion={false}
|
||||
tabList={[
|
||||
{ itemKey: "editor", tab: "模板详情" },
|
||||
{ itemKey: "prepare", tab: "回测准备" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{page === "variants" && (
|
||||
<div>
|
||||
<section className="research-card">
|
||||
<h3>研究输入与假设</h3>
|
||||
{page === "variants" && (
|
||||
<>
|
||||
<div className="research-methods">
|
||||
<Button
|
||||
theme={method === "structure" ? "solid" : "light"}
|
||||
onClick={() => setMethod("structure")}
|
||||
>
|
||||
结构与字段变体
|
||||
</Button>
|
||||
<Button
|
||||
theme={method === "settings" ? "solid" : "light"}
|
||||
onClick={() => setMethod("settings")}
|
||||
>
|
||||
市场与设置变体
|
||||
</Button>
|
||||
</div>
|
||||
<p>
|
||||
{method === "structure"
|
||||
? "神笔马良:保留种子与改动依据,研究新的表达式。"
|
||||
: "缘分一道桥:保持表达式,为各目标市场重新固定输入后检查可用范围。"}
|
||||
</p>
|
||||
<label>
|
||||
种子 Alpha
|
||||
<Input
|
||||
aria-label="种子 Alpha"
|
||||
value={parent}
|
||||
onChange={setParent}
|
||||
placeholder="已同步的 Alpha ID"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<span>组合规模:{combination}</span>
|
||||
<label>
|
||||
固定研究输入
|
||||
<ResearchDataInput
|
||||
label="选择数据准备"
|
||||
ids={inputIds}
|
||||
inputs={inputs}
|
||||
onChange={(ids, rows) => {
|
||||
setInputs(rows);
|
||||
setInputIds(ids);
|
||||
const first = rows.find((r) => r.id === ids[0]);
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="research-hint">
|
||||
从数据准备选择集合;每个集合可包含同范围下多个数据集的字段。
|
||||
</p>
|
||||
{page === "variants" && method === "settings" ? (
|
||||
<label>
|
||||
本次研究假设
|
||||
<TextArea
|
||||
aria-label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
autosize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="research-ai-section">
|
||||
<h4>
|
||||
<IconAIEditLevel3 aria-hidden="true" />
|
||||
本次研究假设
|
||||
</h4>
|
||||
<ResearchInput
|
||||
maxLength={10000}
|
||||
label="本次研究假设"
|
||||
value={hypothesis}
|
||||
onChange={setHypothesis}
|
||||
placeholder={
|
||||
page === "variants"
|
||||
? "描述希望保留的信号与要探索的变体方向…"
|
||||
: "描述经济假设、信号方向与希望验证的关系…"
|
||||
}
|
||||
references={[
|
||||
...selectedInputs.map((input) => ({
|
||||
id: input.id,
|
||||
name: `${input.name} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay} · ${input.field_ids.length} 字段 · ${input.id}`,
|
||||
})),
|
||||
...(page === "variants" && parent
|
||||
? [
|
||||
{
|
||||
id: "parent-alpha",
|
||||
name: `种子 Alpha:${parent}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
disabled={!!busy}
|
||||
canSubmit={!!inputIds.length}
|
||||
busy={busy === "AI 生成"}
|
||||
submitLabel="AI 生成模板"
|
||||
sendHotKey="shift+enter"
|
||||
onSubmit={() => void task("AI 生成", generate)}
|
||||
prompts={
|
||||
page === "variants"
|
||||
? ["保留种子的经济含义,探索不同时间窗口和字段组合"]
|
||||
: ["研究短期价格反转与成交量之间的关系"]
|
||||
}
|
||||
hint={
|
||||
inputIds.length
|
||||
? "基于已关联的固定输入生成,保留假设供下一轮调整。"
|
||||
: "请先关联固定研究输入。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{page !== "variants" || method === "structure" ? (
|
||||
<>
|
||||
<div hidden={page === "templates" && detailTab !== "editor"}>
|
||||
<section className="research-card">
|
||||
<div className="research-section-heading">
|
||||
<h3>
|
||||
模板信息{asset && ` · v${asset.version}`}
|
||||
{dirty && " · 未保存"}
|
||||
</h3>
|
||||
</div>
|
||||
<TemplateEditor
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
definitionsOnly={page === "templates"}
|
||||
/>
|
||||
{page === "templates" ? (
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!!busy || asset?.archived || !dirty}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!busy || !asset || asset.archived}
|
||||
onClick={() => void task("新增版本", save)}
|
||||
>
|
||||
新增版本
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{asset && (
|
||||
<div className="research-toolbar">
|
||||
<span>版本记录</span>
|
||||
<ResearchSelect
|
||||
label="模板历史版本"
|
||||
value={asset.version}
|
||||
optionList={Array.from(
|
||||
{
|
||||
length:
|
||||
assets.find((item) => item.id === asset.id)
|
||||
?.version || asset.version,
|
||||
},
|
||||
(_, i) => ({
|
||||
value: i + 1,
|
||||
label: `v${i + 1}`,
|
||||
}),
|
||||
)}
|
||||
onChange={(version) =>
|
||||
void task("读取历史版本", async () => {
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${asset.id}?version=${version}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAsset(null);
|
||||
setTemplate({
|
||||
...template,
|
||||
name: `${template.name}(副本)`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
另存新模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
disabled={!!busy || asset?.archived}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存模板版本
|
||||
</Button>
|
||||
{asset && !asset.archived && (
|
||||
<DeleteResearchButton
|
||||
name={asset.name}
|
||||
label="模板"
|
||||
disabled={!!busy}
|
||||
onConfirm={() =>
|
||||
task("删除模板", () => removeAsset(asset))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<span>组合规模:{combination}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
展开方式
|
||||
<ResearchSelect
|
||||
label="展开方式"
|
||||
value={mode}
|
||||
optionList={[
|
||||
{ value: "all", label: "全组合" },
|
||||
{ value: "random", label: "可复现随机采样" },
|
||||
]}
|
||||
onChange={(value) => setMode(value as string)}
|
||||
<div hidden={page === "templates" && detailTab !== "prepare"}>
|
||||
<section className="research-card">
|
||||
<h3>回测准备</h3>
|
||||
<div aria-label="回测表达式">
|
||||
<h4>表达式</h4>
|
||||
<pre>{template.expression}</pre>
|
||||
</div>
|
||||
{page === "templates" && (
|
||||
<>
|
||||
<label>
|
||||
数据准备
|
||||
<ResearchDataInput
|
||||
label="选择回测数据准备"
|
||||
ids={inputIds}
|
||||
inputs={inputs}
|
||||
onChange={(ids, rows) => {
|
||||
setInputs(rows);
|
||||
setInputIds(ids);
|
||||
const first = rows.find((r) => r.id === ids[0]);
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{dirty && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description="请返回模板详情,保存模板版本后再生成候选。"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="research-form-grid">
|
||||
<label>
|
||||
展开方式
|
||||
<ResearchSelect
|
||||
label="展开方式"
|
||||
value={mode}
|
||||
optionList={[
|
||||
{ value: "all", label: "全组合" },
|
||||
{ value: "random", label: "可复现随机采样" },
|
||||
]}
|
||||
onChange={(value) => setMode(value as string)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
候选数量上限
|
||||
<InputNumber
|
||||
aria-label="候选数量上限"
|
||||
value={limit}
|
||||
min={1}
|
||||
max={10000}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
随机种子
|
||||
<InputNumber
|
||||
aria-label="随机种子"
|
||||
value={seed}
|
||||
onChange={(value) => setSeed(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<SimulationSettingsEditor
|
||||
validationMode={
|
||||
page === "templates" ? "combination" : "platform"
|
||||
}
|
||||
value={settings}
|
||||
onChange={setSettings}
|
||||
{...options}
|
||||
onRefresh={() => void options.refresh()}
|
||||
fixedScopes={selectedInputs.map((r) => r.scope)}
|
||||
disabled={!!busy}
|
||||
onValidityChange={setSettingsValid}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
候选数量上限
|
||||
<InputNumber
|
||||
aria-label="候选数量上限"
|
||||
value={limit}
|
||||
min={1}
|
||||
max={10000}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
随机种子
|
||||
<InputNumber
|
||||
aria-label="随机种子"
|
||||
value={seed}
|
||||
onChange={(value) => setSeed(Number(value))}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={
|
||||
dirty ||
|
||||
!inputIds.length ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!!busy ||
|
||||
!settingsValid
|
||||
}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
生成候选集合
|
||||
</Button>
|
||||
{(!inputIds.length ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!settingsValid) && (
|
||||
<p className="research-hint">
|
||||
待完成:
|
||||
{[
|
||||
!inputIds.length && "选择固定研究输入",
|
||||
page === "variants" && !hypothesis && "填写研究假设",
|
||||
!settingsValid && "检查模拟设置",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("、")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<SimulationSettingsEditor
|
||||
value={settings}
|
||||
onChange={setSettings}
|
||||
{...options}
|
||||
onRefresh={() => void options.refresh()}
|
||||
fixedScopes={selectedInputs.map((r) => r.scope)}
|
||||
disabled={!!busy}
|
||||
onValidityChange={setSettingsValid}
|
||||
/>
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={
|
||||
dirty ||
|
||||
!inputIds.length ||
|
||||
!hypothesis ||
|
||||
!!busy ||
|
||||
!settingsValid
|
||||
}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
保存候选研究记录
|
||||
</Button>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className="research-card">
|
||||
<h3>目标范围检查</h3>
|
||||
@@ -789,76 +932,19 @@ export function ResearchWorkspace({
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
{experiment && (
|
||||
<ExperimentView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
deleting={!!busy}
|
||||
onDelete={() =>
|
||||
task("删除生成记录", () => removeExperiment(experiment))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{page === "templates" && (
|
||||
<details className="research-card">
|
||||
<summary>导入旧模板</summary>
|
||||
<p>
|
||||
粘贴 cnhk 模板 JSON
|
||||
或模板数组。先预览转换结果,同名模板不会自动覆盖。
|
||||
</p>
|
||||
<TextArea
|
||||
aria-label="导入模板 JSON"
|
||||
value={importText}
|
||||
onChange={(text) => {
|
||||
setImportText(text);
|
||||
setImportResult(null);
|
||||
}}
|
||||
autosize={{ minRows: 4, maxRows: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!!busy || !importText}
|
||||
onClick={() =>
|
||||
void task("预览导入", async () => {
|
||||
const data = JSON.parse(importText);
|
||||
setImportResult(
|
||||
await post("/research/templates/import-preview", {
|
||||
templates: Array.isArray(data) ? data : [data],
|
||||
}),
|
||||
);
|
||||
})
|
||||
<div hidden={page === "templates" && detailTab !== "prepare"}>
|
||||
{experiment && (
|
||||
<CandidateView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
deleting={!!busy}
|
||||
onDelete={() =>
|
||||
task("删除生成记录", () => removeExperiment(experiment))
|
||||
}
|
||||
>
|
||||
预览导入差异
|
||||
</Button>
|
||||
{importResult && (
|
||||
<>
|
||||
<pre>{JSON.stringify(importResult, null, 2)}</pre>
|
||||
<Button
|
||||
disabled={
|
||||
!!busy ||
|
||||
!!importResult.errors?.length ||
|
||||
!!importResult.conflicts?.length
|
||||
}
|
||||
onClick={() =>
|
||||
void task("导入模板", async () => {
|
||||
await post("/research/templates/import", {
|
||||
templates: importResult.templates,
|
||||
digest: importResult.digest,
|
||||
});
|
||||
setImportResult(null);
|
||||
setImportText("");
|
||||
await refresh();
|
||||
Toast.success("导入完成");
|
||||
})
|
||||
}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{page === "variants" && (
|
||||
<ComparisonPanel key={parent} baseline={parent} />
|
||||
)}
|
||||
|
||||
@@ -44,10 +44,12 @@ function filterSignature(filters: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
export function SavedViews({
|
||||
managementScope = "non_super",
|
||||
filters,
|
||||
columns,
|
||||
submission,
|
||||
submissionBlocked,
|
||||
ppacCandidate,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onSubmissionChange,
|
||||
@@ -55,10 +57,12 @@ export function SavedViews({
|
||||
suspended,
|
||||
onOverlay,
|
||||
}: {
|
||||
managementScope?: "super" | "non_super";
|
||||
filters: Record<string, unknown>;
|
||||
columns: string[];
|
||||
submission: Submission;
|
||||
submissionBlocked: boolean;
|
||||
ppacCandidate: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onSubmissionChange: (submission: string) => void;
|
||||
@@ -73,6 +77,11 @@ export function SavedViews({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const current = items.find((item) => item.id === selectedId);
|
||||
const fixedTab = ppacCandidate
|
||||
? "PPAC_CANDIDATE"
|
||||
: submissionBlocked
|
||||
? "SUBMISSION_BLOCKED"
|
||||
: submission;
|
||||
const modified =
|
||||
current &&
|
||||
(filterSignature(filters) !== filterSignature(current.content.filters) ||
|
||||
@@ -90,13 +99,22 @@ export function SavedViews({
|
||||
views.push(...result.items);
|
||||
if (views.length >= result.total || result.items.length === 0) break;
|
||||
}
|
||||
if (!controller.signal.aborted) setItems(views);
|
||||
if (!controller.signal.aborted)
|
||||
setItems(
|
||||
views.filter(
|
||||
(v) =>
|
||||
(v.content.filters.management_scope ??
|
||||
(v.content.filters.alpha_type === "SUPER"
|
||||
? "super"
|
||||
: "non_super")) === managementScope,
|
||||
),
|
||||
);
|
||||
}
|
||||
void load().catch((error: Error) => {
|
||||
if (!controller.signal.aborted) Toast.error(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [revision]);
|
||||
}, [revision, managementScope]);
|
||||
|
||||
function openEditor(mode: Editor["mode"], view?: View) {
|
||||
const content = view?.content ?? { name: "", filters, columns };
|
||||
@@ -154,10 +172,7 @@ export function SavedViews({
|
||||
setItems((previous) =>
|
||||
previous.filter((item) => item.id !== deleting.id),
|
||||
);
|
||||
if (selectedId === deleting.id)
|
||||
onSubmissionChange(
|
||||
submissionBlocked ? "SUBMISSION_BLOCKED" : submission,
|
||||
);
|
||||
if (selectedId === deleting.id) onSubmissionChange(fixedTab);
|
||||
setDeleting(null);
|
||||
Toast.success("视图已删除");
|
||||
} catch (error) {
|
||||
@@ -174,10 +189,7 @@ export function SavedViews({
|
||||
type="card"
|
||||
collapsible="auto"
|
||||
tabPaneMotion={false}
|
||||
activeKey={
|
||||
selectedId ??
|
||||
(submissionBlocked ? "SUBMISSION_BLOCKED" : submission)
|
||||
}
|
||||
activeKey={selectedId ?? fixedTab}
|
||||
onChange={(key) => {
|
||||
const view = items.find((item) => item.id === key);
|
||||
if (view) {
|
||||
@@ -201,6 +213,11 @@ export function SavedViews({
|
||||
tab: "提交受阻",
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
itemKey: "PPAC_CANDIDATE",
|
||||
tab: "候选PPAC",
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
},
|
||||
...items.map((view) => ({
|
||||
itemKey: view.id,
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./style.css";
|
||||
|
||||
export const sourceLabel = (kind: string) =>
|
||||
({
|
||||
superalpha: "Super Alpha 研究",
|
||||
chatbox: "Chatbox 研究",
|
||||
manual: "手工研究",
|
||||
mcp: "MCP 研究",
|
||||
@@ -33,6 +34,24 @@ export function SourceDetails({
|
||||
<p>来源引用:{source.reference}</p>
|
||||
)}
|
||||
<div className="inline-actions">
|
||||
{(source.superalpha_plan_id ||
|
||||
((source.kind === "superalpha" ||
|
||||
source.research_kind === "superalpha") &&
|
||||
source.research_id)) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onAction({
|
||||
type: "open_superalpha_research",
|
||||
plan_id: source.superalpha_plan_id ?? undefined,
|
||||
version: source.superalpha_plan_version ?? undefined,
|
||||
experiment_id: source.research_id ?? undefined,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
>
|
||||
查看 Super Alpha 研究
|
||||
</Button>
|
||||
)}
|
||||
{source.research_id &&
|
||||
["template", "variant", "feature", "pipeline", "quantflow"].includes(
|
||||
source.kind,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import { CandidateTable } from "./CandidateTable";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
|
||||
/** The generated collection is the confirmation surface for a template backtest. */
|
||||
export function TemplateCandidateSet({
|
||||
experiment,
|
||||
onAction,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
experiment: Experiment;
|
||||
onAction: (action: UIAction) => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
deleting?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(() =>
|
||||
experiment.candidates.map((c) => c.client_item_id),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [runs, setRuns] = useState(experiment.backtest_run_ids);
|
||||
const attempt = useRef<{ selection: string; key: string } | null>(null);
|
||||
const pending = useRef(false);
|
||||
async function backtest() {
|
||||
if (pending.current || !selected.length) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
// Retain the same request key after network failure: retry must not create a second run.
|
||||
const selection = JSON.stringify([...selected].sort());
|
||||
if (attempt.current?.selection !== selection) {
|
||||
attempt.current = { selection, key: crypto.randomUUID() };
|
||||
}
|
||||
try {
|
||||
const result = await post<{ backtest_run_id: string }>(
|
||||
`/research/experiments/${experiment.id}/backtest`,
|
||||
{
|
||||
candidate_ids: selected,
|
||||
idempotency_key: attempt.current.key,
|
||||
},
|
||||
);
|
||||
setRuns((old) => [...new Set([...old, result.backtest_run_id])]);
|
||||
onAction({
|
||||
type: "open_backtest",
|
||||
run_id: result.backtest_run_id,
|
||||
nonce: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
Toast.error((error as Error).message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="research-card" aria-label="回测候选集合">
|
||||
<h3>回测候选集合</h3>
|
||||
<details>
|
||||
<summary>研究描述</summary>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
</details>
|
||||
{experiment.archived && (
|
||||
<Banner type="info" description="此候选集合已删除,保留历史关联。" />
|
||||
)}
|
||||
<p>确认表达式和参数后,点击“回测”将所选候选交给回测研究批量执行。</p>
|
||||
<CandidateTable
|
||||
candidates={experiment.candidates}
|
||||
selected={selected}
|
||||
onSelectionChange={setSelected}
|
||||
disabled={busy || deleting || experiment.archived}
|
||||
/>
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!selected.length || deleting || experiment.archived}
|
||||
loading={busy}
|
||||
onClick={() => void backtest()}
|
||||
>
|
||||
回测({selected.length})
|
||||
</Button>
|
||||
{onDelete && !experiment.archived && (
|
||||
<DeleteResearchButton
|
||||
name={experiment.name}
|
||||
label="候选集合"
|
||||
disabled={busy || deleting}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
)}
|
||||
{runs.map((id, index) => (
|
||||
<Button
|
||||
key={id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
|
||||
}
|
||||
>
|
||||
关联回测 {index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,12 @@ export function TemplateEditor({
|
||||
value,
|
||||
onChange,
|
||||
inputs,
|
||||
definitionsOnly = false,
|
||||
}: {
|
||||
value: Template;
|
||||
onChange: (value: Template) => void;
|
||||
inputs: InputSnapshot[];
|
||||
definitionsOnly?: boolean;
|
||||
}) {
|
||||
function expression(text: string) {
|
||||
const names = [
|
||||
@@ -33,11 +35,13 @@ export function TemplateEditor({
|
||||
variables: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
value.variables[name] || {
|
||||
kind: "field",
|
||||
field_type: "MATRIX",
|
||||
values: [],
|
||||
},
|
||||
Object.hasOwn(value.variables, name)
|
||||
? value.variables[name]
|
||||
: {
|
||||
kind: "field",
|
||||
field_type: "MATRIX",
|
||||
values: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
@@ -95,86 +99,137 @@ export function TemplateEditor({
|
||||
使用 {"{name}"}{" "}
|
||||
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
|
||||
</p>
|
||||
{Object.entries(value.variables).map(([name, item]) => (
|
||||
<div className="research-variable" key={name}>
|
||||
<strong>{`{${name}}`}</strong>
|
||||
<ResearchSelect
|
||||
label={`${name} 变量类型`}
|
||||
value={item.kind}
|
||||
optionList={Object.entries(kinds).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
onChange={(kind) =>
|
||||
variable(name, {
|
||||
kind: kind as Variable["kind"],
|
||||
values: [],
|
||||
...(kind === "field" ? { field_type: "MATRIX" } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{item.kind === "field" && (
|
||||
{definitionsOnly && Object.keys(value.variables).length > 0 && (
|
||||
<h4>字段配置</h4>
|
||||
)}
|
||||
{Object.entries(value.variables).map(([name, item]) =>
|
||||
definitionsOnly ? (
|
||||
<div className="research-variable-definition" key={name}>
|
||||
<strong>{`{${name}}`}</strong>
|
||||
<label>
|
||||
字段类型
|
||||
<ResearchSelect
|
||||
label={`${name} 字段类型`}
|
||||
value={item.kind === "field" ? item.field_type : item.kind}
|
||||
optionList={[
|
||||
...["MATRIX", "VECTOR", "GROUP"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
})),
|
||||
...Object.entries(kinds)
|
||||
.filter(([kind]) => kind !== "field")
|
||||
.map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
onChange={(type) => {
|
||||
const isField = ["MATRIX", "VECTOR", "GROUP"].includes(
|
||||
String(type),
|
||||
);
|
||||
variable(name, {
|
||||
kind: isField ? "field" : (type as Variable["kind"]),
|
||||
...(isField
|
||||
? { field_type: type as Variable["field_type"] }
|
||||
: {}),
|
||||
description: item.description || "",
|
||||
values: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
字段描述
|
||||
<Input
|
||||
aria-label={`${name} 字段描述`}
|
||||
value={item.description || ""}
|
||||
maxLength={3000}
|
||||
placeholder="说明字段含义与用途"
|
||||
onChange={(description) =>
|
||||
variable(name, { ...item, description })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="research-variable" key={name}>
|
||||
<strong>{`{${name}}`}</strong>
|
||||
<ResearchSelect
|
||||
label={`${name} 字段类型`}
|
||||
value={item.field_type}
|
||||
optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({
|
||||
label={`${name} 变量类型`}
|
||||
value={item.kind}
|
||||
optionList={Object.entries(kinds).map(([value, label]) => ({
|
||||
value,
|
||||
label: value,
|
||||
label,
|
||||
}))}
|
||||
onChange={(field_type) =>
|
||||
onChange={(kind) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
field_type: field_type as Variable["field_type"],
|
||||
kind: kind as Variable["kind"],
|
||||
values: [],
|
||||
...(kind === "field" ? { field_type: "MATRIX" } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TextArea
|
||||
aria-label={`${name} 候选取值`}
|
||||
placeholder={
|
||||
item.kind === "fragment"
|
||||
? "每行一个完整表达式片段"
|
||||
: "每行一个取值"
|
||||
}
|
||||
value={item.values.join("\n")}
|
||||
autosize={{ minRows: 2, maxRows: 6 }}
|
||||
onChange={(text) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
values: text
|
||||
.split("\n")
|
||||
.map((v) =>
|
||||
["integer", "number"].includes(item.kind) && v.trim() !== ""
|
||||
? Number(v)
|
||||
: v,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{item.kind === "field" && (
|
||||
<Button
|
||||
disabled={!inputs.length}
|
||||
onClick={() =>
|
||||
{item.kind === "field" && (
|
||||
<ResearchSelect
|
||||
label={`${name} 字段类型`}
|
||||
value={item.field_type}
|
||||
optionList={["MATRIX", "VECTOR", "GROUP"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
}))}
|
||||
onChange={(field_type) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
field_type: field_type as Variable["field_type"],
|
||||
values: [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TextArea
|
||||
aria-label={`${name} 候选取值`}
|
||||
placeholder={
|
||||
item.kind === "fragment"
|
||||
? "每行一个完整表达式片段"
|
||||
: "每行一个取值"
|
||||
}
|
||||
value={item.values.join("\n")}
|
||||
autosize={{ minRows: 2, maxRows: 6 }}
|
||||
onChange={(text) =>
|
||||
variable(name, {
|
||||
...item,
|
||||
values: [
|
||||
...new Set(
|
||||
inputs.flatMap((input) =>
|
||||
input.field_ids.filter(
|
||||
(id) => input.field_types[id] === item.field_type,
|
||||
),
|
||||
),
|
||||
values: text
|
||||
.split("\n")
|
||||
.map((v) =>
|
||||
["integer", "number"].includes(item.kind) &&
|
||||
v.trim() !== ""
|
||||
? Number(v)
|
||||
: v,
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
使用固定输入中的 {item.field_type} 字段
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
/>
|
||||
{item.kind === "field" && (
|
||||
<Button
|
||||
disabled={!inputs.length}
|
||||
onClick={() =>
|
||||
variable(name, {
|
||||
...item,
|
||||
values: [
|
||||
...new Set(
|
||||
inputs.flatMap((input) =>
|
||||
input.field_ids.filter(
|
||||
(id) => input.field_types[id] === item.field_type,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
使用固定输入中的 {item.field_type} 字段
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@
|
||||
.research-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.research-template-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.research-template-expression {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.research-table .semi-table-row-cell {
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -362,3 +374,32 @@
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.research-variable-definition {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.5fr) minmax(140px, 1fr) minmax(
|
||||
200px,
|
||||
2fr
|
||||
);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
}
|
||||
.research-variable-definition > strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.research-variable-definition {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.research-card .research-candidate-table .semi-checkbox {
|
||||
margin: 0;
|
||||
flex-direction: row;
|
||||
}
|
||||
.research-candidate-table .workspace-cell code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Variable = {
|
||||
| "string"
|
||||
| "fragment";
|
||||
values: (string | number)[];
|
||||
description?: string;
|
||||
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
|
||||
};
|
||||
export type Template = {
|
||||
@@ -18,7 +19,6 @@ export type Template = {
|
||||
expression: string;
|
||||
variables: Record<string, Variable>;
|
||||
category: "template" | "fragment";
|
||||
scope?: InputSnapshot["scope"] | null;
|
||||
};
|
||||
export type Asset = {
|
||||
id: string;
|
||||
@@ -55,7 +55,7 @@ export type InputSnapshot = {
|
||||
created_at: string;
|
||||
};
|
||||
export type ResearchCandidate = Candidate & {
|
||||
validation: {
|
||||
validation?: {
|
||||
status: string;
|
||||
syntax: string[];
|
||||
types: string[];
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
|
||||
export function SimulationSettingsEditor({
|
||||
alphaType = "REGULAR",
|
||||
value,
|
||||
onChange,
|
||||
rows,
|
||||
@@ -27,7 +28,10 @@ export function SimulationSettingsEditor({
|
||||
fixedScopes,
|
||||
disabled = false,
|
||||
onValidityChange,
|
||||
validationMode = "platform",
|
||||
}: {
|
||||
validationMode?: "platform" | "combination";
|
||||
alphaType?: "REGULAR" | "SUPER";
|
||||
value: SimulationSettings;
|
||||
onChange: (value: SimulationSettings) => void;
|
||||
rows: SettingsRow[];
|
||||
@@ -41,18 +45,36 @@ export function SimulationSettingsEditor({
|
||||
}) {
|
||||
const scope = settingsScope(value);
|
||||
const row = rows.find((r) => sameScope(r, scope));
|
||||
const errors = settingsErrors(value, rows, fixedScopes);
|
||||
const combinationOnly = validationMode === "combination";
|
||||
const errors = combinationOnly
|
||||
? !fixedScopes?.length || fixedScopes.some((s) => !sameScope(s, scope))
|
||||
? ["所选数据准备必须具有相同组合,并与回测组合一致"]
|
||||
: []
|
||||
: settingsErrors(value, rows, fixedScopes);
|
||||
const [scopeReady, setScopeReady] = useState(false);
|
||||
const valid =
|
||||
!loading &&
|
||||
!error &&
|
||||
!errors.length &&
|
||||
(fixedScopes !== undefined || scopeReady);
|
||||
const valid = combinationOnly
|
||||
? !errors.length
|
||||
: !loading &&
|
||||
!error &&
|
||||
!errors.length &&
|
||||
(fixedScopes !== undefined || scopeReady);
|
||||
useEffect(() => {
|
||||
onValidityChange?.(valid);
|
||||
}, [valid, onValidityChange]);
|
||||
function field(key: string) {
|
||||
const options = fieldOptions(row, key);
|
||||
const options = combinationOnly
|
||||
? key === "neutralization"
|
||||
? {
|
||||
choices: [
|
||||
...new Set([
|
||||
value.neutralization,
|
||||
...(row?.neutralizations ??
|
||||
rows.flatMap((r) => r.neutralizations)),
|
||||
]),
|
||||
],
|
||||
}
|
||||
: fieldOptions(undefined, key)
|
||||
: fieldOptions(row, key);
|
||||
const current =
|
||||
value[key as keyof SimulationSettings] ??
|
||||
(key === "maxPosition" ? "OFF" : undefined);
|
||||
@@ -69,7 +91,7 @@ export function SimulationSettingsEditor({
|
||||
value={typeof current === "boolean" ? String(current) : current}
|
||||
disabled={
|
||||
disabled ||
|
||||
loading ||
|
||||
(loading && !combinationOnly) ||
|
||||
!options.choices.length ||
|
||||
(options.choices.length === 1 && !message)
|
||||
}
|
||||
@@ -95,11 +117,13 @@ export function SimulationSettingsEditor({
|
||||
? current
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || (loading && !combinationOnly)}
|
||||
min={options.minimum}
|
||||
max={options.maximum}
|
||||
precision={key === "decay" ? 0 : undefined}
|
||||
step={key === "decay" ? 1 : 0.01}
|
||||
precision={
|
||||
["decay", "selectionLimit"].includes(key) ? 0 : undefined
|
||||
}
|
||||
step={["decay", "selectionLimit"].includes(key) ? 1 : 0.01}
|
||||
validateStatus={message ? "error" : "default"}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
@@ -125,16 +149,30 @@ export function SimulationSettingsEditor({
|
||||
选项更新时间:{formatTime(fetchedAt ?? null)}
|
||||
</small>
|
||||
{error && (
|
||||
<Banner type="warning" description={`${error};请重试同步合法设置`} />
|
||||
<Banner
|
||||
type="warning"
|
||||
description={
|
||||
combinationOnly
|
||||
? "参数选项暂不可用,可使用当前参数继续生成候选。"
|
||||
: `${error};请重试同步合法设置`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!loading && !rows.length && !error && (
|
||||
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
combinationOnly
|
||||
? "可同步参数选项辅助选择,当前参数仍可用于生成候选。"
|
||||
: "尚无参数选项,请先同步合法设置。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ScopePicker
|
||||
value={scope}
|
||||
options={asScopeOptions(rows)}
|
||||
readOnly={fixedScopes !== undefined}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || (loading && !combinationOnly)}
|
||||
onValidityChange={setScopeReady}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
@@ -161,6 +199,23 @@ export function SimulationSettingsEditor({
|
||||
<div className="settings-grid">
|
||||
{["neutralization", "decay", "truncation"].map(field)}
|
||||
</div>
|
||||
{alphaType === "SUPER" && (
|
||||
<>
|
||||
<div className="settings-grid">
|
||||
{["selectionHandling", "selectionLimit", "componentActivation"].map(
|
||||
field,
|
||||
)}
|
||||
</div>
|
||||
{["selectionHandling", "selectionLimit", "componentActivation"].some(
|
||||
(k) => !row?.fields?.[k],
|
||||
) && (
|
||||
<small className="settings-hint">
|
||||
平台元数据未提供全部 SUPER
|
||||
设置范围;当前显示本地契约,账户适用性未核实。
|
||||
</small>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<details>
|
||||
<summary>更多参数</summary>
|
||||
<div className="settings-grid">
|
||||
|
||||
@@ -86,6 +86,9 @@ export function asScopeOptions(rows: SettingsRow[]): ScopeOption[] {
|
||||
return rows.map((row) => ({ ...row, universes: [row.universe] }));
|
||||
}
|
||||
export const parameterLabels: Record<string, string> = {
|
||||
selectionHandling: "Selection Handling",
|
||||
selectionLimit: "Selection Limit",
|
||||
componentActivation: "Component Activation",
|
||||
neutralization: "Neutralization",
|
||||
decay: "Decay",
|
||||
truncation: "Truncation",
|
||||
@@ -99,6 +102,9 @@ export const parameterLabels: Record<string, string> = {
|
||||
};
|
||||
// These are application contract limits, not a substitute for account-specific market choices.
|
||||
const contractFields: Record<string, FieldOption> = {
|
||||
selectionHandling: { choices: ["POSITIVE", "NON_ZERO", "NON_NAN"] },
|
||||
componentActivation: { choices: ["IS", "OS"] },
|
||||
selectionLimit: { minimum: 1, maximum: 100000 },
|
||||
decay: { minimum: 0, maximum: 10000 },
|
||||
truncation: { minimum: 0, maximum: 1 },
|
||||
pasteurization: { choices: ["ON", "OFF"] },
|
||||
@@ -143,7 +149,10 @@ export function fieldOptions(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (result.choices && (key === "decay" || key === "truncation")) {
|
||||
if (
|
||||
result.choices &&
|
||||
["decay", "truncation", "selectionLimit"].includes(key)
|
||||
) {
|
||||
result.choices = result.choices.filter(
|
||||
(v) =>
|
||||
typeof v === "number" &&
|
||||
@@ -171,6 +180,13 @@ export function settingsErrors(
|
||||
)
|
||||
errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
|
||||
for (const key of Object.keys(parameterLabels)) {
|
||||
if (
|
||||
["selectionHandling", "selectionLimit", "componentActivation"].includes(
|
||||
key,
|
||||
) &&
|
||||
value.selectionHandling === undefined
|
||||
)
|
||||
continue;
|
||||
const current =
|
||||
value[key as keyof SimulationSettings] ??
|
||||
(key === "maxPosition" ? "OFF" : undefined);
|
||||
@@ -183,12 +199,13 @@ export function settingsErrors(
|
||||
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
|
||||
);
|
||||
if (
|
||||
(key === "decay" || key === "truncation") &&
|
||||
["decay", "truncation", "selectionLimit"].includes(key) &&
|
||||
(typeof current !== "number" ||
|
||||
!Number.isFinite(current) ||
|
||||
current < options.minimum! ||
|
||||
current > options.maximum! ||
|
||||
(key === "decay" && !Number.isInteger(current)))
|
||||
(["decay", "selectionLimit"].includes(key) &&
|
||||
!Number.isInteger(current)))
|
||||
)
|
||||
errors.push(
|
||||
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banner, Pagination, Spin } from "@douyinfe/semi-ui-19";
|
||||
import { api, displayValue, formatTime } from "../api";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import type { Components } from "./types";
|
||||
|
||||
/** Component evidence is always read from its own immutable observation. */
|
||||
export function ComponentsPanel({
|
||||
url,
|
||||
timezone,
|
||||
}: {
|
||||
url: string;
|
||||
timezone?: string;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<Components>();
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setData(undefined);
|
||||
}, [url]);
|
||||
useEffect(() => {
|
||||
const c = new AbortController();
|
||||
setError("");
|
||||
void api<Components>(
|
||||
`${url}${url.includes("?") ? "&" : "?"}limit=25&offset=${(page - 1) * 25}`,
|
||||
{ signal: c.signal },
|
||||
)
|
||||
.then((value) => {
|
||||
if (!c.signal.aborted) setData(value);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!c.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => c.abort();
|
||||
}, [url, page]);
|
||||
if (error) return <Banner type="danger" description={error} />;
|
||||
if (!data) return <Spin />;
|
||||
return (
|
||||
<section className="super-components">
|
||||
<p>
|
||||
{data.source === "preview" ? "Selection 预览组件" : "回测实际组件"} ·{" "}
|
||||
{data.complete
|
||||
? `已核实 ${data.reported_total ?? data.total} 个`
|
||||
: "完整性未核实"}{" "}
|
||||
· 观察时间 {formatTime(data.observed_at, timezone)}
|
||||
</p>
|
||||
{data.complete && data.total === 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description="组件池为空,回测可能无法产生结果。请调整 Selection。"
|
||||
/>
|
||||
)}
|
||||
{(data.warnings ?? []).map((w, i) => (
|
||||
<Banner key={i} type="warning" description={displayValue(w)} />
|
||||
))}
|
||||
<p className="muted">组件指纹:{data.component_hash ?? "未核实"}</p>
|
||||
<WorkspaceTable
|
||||
rowKey="id"
|
||||
dataSource={data.items}
|
||||
scroll={{ x: 1100, y: 320 }}
|
||||
columns={[
|
||||
{ title: "组件 ID", dataIndex: "id", width: 190 },
|
||||
{
|
||||
title: "平台选择值",
|
||||
width: 180,
|
||||
render: (_, r) =>
|
||||
displayValue(r?.value ?? r?.selectionValue ?? r?.selection),
|
||||
},
|
||||
{
|
||||
title: "可用指标与平台返回字段",
|
||||
width: 730,
|
||||
render: (_, r) => JSON.stringify(r),
|
||||
},
|
||||
]}
|
||||
empty="未提供组件"
|
||||
/>
|
||||
<footer className="workspace-table-footer super-toolbar">
|
||||
<span>已保存 {data.total} 条</span>
|
||||
<Pagination
|
||||
total={data.total}
|
||||
currentPage={page}
|
||||
pageSize={25}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user