Compare commits

..

16 Commits

Author SHA1 Message Date
yuxuanhui 6bfc787691 更新生产部署配置,调整工作流以支持 prod 分支,移除不再使用的数据库网络变量,并优化文档说明
Deploy production / deploy (push) Successful in 30s
2026-09-25 11:13:19 +08:00
yuxuanhui a1b160e1b0 Update knowledge base with recent forum insights and clarifications
- Added new sections on missing data handling and experimental variables in data and signal semantics (2026-09-25).
- Included clarifications on clustering representatives and candidate evaluations in portfolio and correlation optimization (2026-09-25).
- Updated README to reflect the latest forum synchronization and new post counts, including significant additions and revisions across multiple topics (2026-09-25).
2026-09-25 10:38:15 +08:00
yuxuanhui 79b432c3d0 Revert "Refactor Gitea production deployment process to utilize SSH for remote operations"
Deploy production / deploy (push) Successful in 24s
This reverts commit 99bc36439e.
2026-09-25 00:54:16 +08:00
yuxuanhui a3cb6dbacf Revert "feat(deployment): add QEMU setup for cross-platform builds and improve Docker Buildx configuration"
This reverts commit 5a7f39726b.
2026-09-25 00:54:12 +08:00
yuxuanhui 5a7f39726b feat(deployment): add QEMU setup for cross-platform builds and improve Docker Buildx configuration
Deploy production / deploy (push) Has been cancelled
2026-09-25 00:00:08 +08:00
yuxuanhui 99bc36439e Refactor Gitea production deployment process to utilize SSH for remote operations
Deploy production / deploy (push) Failing after 8s
- Updated deployment specification to reflect the new architecture involving servers A, B, and C.
- Revised README to describe the new deployment method using Gitea Runner and SSH.
- Modified `compose.production.yaml` to remove build context and use image tags directly.
- Enhanced deployment documentation to clarify configuration steps and environment variable requirements.
- Introduced `deploy-remote.sh` script for handling remote deployment tasks over SSH.
- Added unit tests for deployment scripts to ensure robustness and error handling.
- Updated `deploy-production.sh` to streamline image pulling and deployment processes.
2026-09-24 23:47:07 +08:00
yuxuanhui 69c19ed25f Refactor project components and workflows
Deploy production / deploy (push) Successful in 51s
2026-09-20 11:20:51 +08:00
yuxuanhui 13a2168ca5 Simplify template candidate confirmation and direct batch backtesting
Deploy production / deploy (push) Successful in 57s
2026-09-20 11:01:07 +08:00
yuxuanhui 07dd767c52 fix(research): remove template scope constraints and migrate stored templates
Deploy production / deploy (push) Successful in 55s
2026-09-20 10:17:07 +08:00
yuxuanhui 34f1a4fa77 feat(research): streamline template details and enable bot versioning
Deploy production / deploy (push) Successful in 1m35s
2026-09-20 09:57:37 +08:00
yuxuanhui ba60d8e5c4 feat(observability): add observability labels and logging configuration for backend, migrate, and web services
Deploy production / deploy (push) Successful in 22s
2026-09-16 16:44:29 +08:00
yuxuanhui 20f6d0fc51 feat(research): separate template editing and backtest preparation tabs
Deploy production / deploy (push) Successful in 36s
2026-09-13 15:09:56 +08:00
yuxuanhui db449f0915 fix: keep template table cells compact with Semi tooltips 2026-09-13 14:31:03 +08:00
yuxuanhui 8444e3055e feat: add PPAC candidate tab and status
Deploy production / deploy (push) Successful in 56s
2026-09-13 12:57:38 +08:00
yuxuanhui e256d6fef1 feat: add Super Alpha research, management and MCP workflows
Deploy production / deploy (push) Successful in 56s
2026-09-13 12:32:16 +08:00
yuxuanhui 7c8188df9c feat(mcp): add quarterly pyramid distribution lookup 2026-09-13 10:39:31 +08:00
110 changed files with 7768 additions and 1612 deletions
+5 -4
View File
@@ -1,13 +1,15 @@
name: Deploy production name: Deploy production
on: on:
push: push:
branches: [main] branches: [prod]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
# Reuse the runner that deploys zhixing-system to the same Docker host. # Manual runs must also select prod before receiving production credentials.
runs-on: ubuntu-latest 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: steps:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
@@ -22,7 +24,6 @@ jobs:
WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }} WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }} ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }}
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
MCP_ENABLED: ${{ vars.MCP_ENABLED }} MCP_ENABLED: ${{ vars.MCP_ENABLED }}
run: bash scripts/deploy-production.sh 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 提示。
+14
View File
@@ -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。验证使用隔离数据库和模拟平台。
+2
View File
@@ -45,6 +45,8 @@ class PageContext(Contract):
page: Literal[ page: Literal[
"home", "home",
"alphas", "alphas",
"superalphas",
"superalpha-research",
"account", "account",
"datasets", "datasets",
"fields", "fields",
+22 -4
View File
@@ -31,16 +31,20 @@ def snapshot_columns(settings, metrics, checks, *, checked=False):
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check 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 with no failures is PASS. WARNING/PENDING do not count as failures, matching
the legacy workflow. Empty, malformed or unknown results remain PENDING. 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 {} settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {} metrics = metrics if isinstance(metrics, dict) else {}
blocked = submission_limits(checks)["status"] == "blocked" blocked = submission_limits(checks)["status"] == "blocked"
checks, _ = split_checks(checks) checks, _ = split_checks(checks)
valid = [check for check in checks if isinstance(check, dict)] 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)} 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" 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): 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" check_type = "PENDING"
@@ -75,7 +79,7 @@ def check_summary(checks, *, check_type):
"check_type": check_type, "check_type": check_type,
"failed_checks": failed_checks(checks), "failed_checks": failed_checks(checks),
"submission_limits": submission_limits(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): def list_statement(filters):
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id) 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: if filters.submission:
query = query.where(submission_condition(filters.submission)) query = query.where(submission_condition(filters.submission))
if filters.submission_blocked is not None: if filters.submission_blocked is not None:
query = query.where(Alpha.submission_blocked == filters.submission_blocked) query = query.where(Alpha.submission_blocked == filters.submission_blocked)
if filters.submission_blocked: if filters.submission_blocked:
query = query.where(submission_condition("UNSUBMITTED")) 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 if (filters.local_correlation_status is not None or filters.local_correlation_min is not None
or filters.local_correlation_max 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. # 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 = {k: getattr(item, k) for k in keys}
result["failed_checks"] = failed_checks(item.checks) result["failed_checks"] = failed_checks(item.checks)
result["expression_preview"] = (item.expression or item.selection or "")[:240] 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"] = { result["research"] = {
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version") k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at", "version")
} }
+1 -1
View File
@@ -96,7 +96,7 @@ async def wake_backtests(runner, result):
runner.backtests.wake.set() 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 = ( CAPABILITIES = (
+38 -10
View File
@@ -27,25 +27,47 @@ class SimulationSettings(Contract):
maxPosition: Literal["ON", "OFF"] = "OFF" 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): class Candidate(Contract):
client_item_id: str = Field(min_length=1, max_length=100) client_item_id: str = Field(min_length=1, max_length=100)
expression: str = Field(min_length=1, max_length=20000) expression: str = Field(default="", max_length=20000)
settings: SimulationSettings selection: str | None = Field(default=None, max_length=20000)
alpha_type: Literal["REGULAR"] = "REGULAR" 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 @classmethod
def nonempty(cls, value): def nonempty(cls, value):
value = value.strip() return value.strip() if value is not None else None
if not value:
raise ValueError("表达式不能为空") @model_validator(mode="after")
return value 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): 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()} return {"type": self.alpha_type, "regular": self.expression, "settings": self.settings.model_dump()}
class Source(Contract): class Source(Contract):
research_kind: str | None = Field(default=None, max_length=50)
kind: str = Field(default="manual", min_length=1, max_length=100) kind: str = Field(default="manual", min_length=1, max_length=100)
reference: str | None = Field(default=None, max_length=200) reference: str | None = Field(default=None, max_length=200)
batch_id: 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) research_id: str | None = Field(default=None, max_length=200)
parent_run_id: str | None = Field(default=None, max_length=36) parent_run_id: str | None = Field(default=None, max_length=36)
hypothesis: str | None = Field(default=None, max_length=2000) 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): class SourceOutput(Source):
@@ -129,7 +154,7 @@ def fingerprint(payload: dict) -> str:
def group_key(candidate: dict): def group_key(candidate: dict):
settings = candidate["settings"] 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): class ReferenceInput(Contract):
@@ -203,7 +228,10 @@ class ItemOutput(Contract):
id: str id: str
client_item_id: str client_item_id: str
expression: 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 attempt_id: str
platform_status: str platform_status: str
collection_status: str collection_status: str
+16 -1
View File
@@ -117,13 +117,14 @@ async def runs(
source: str | None = Query(None, max_length=100), source: str | None = Query(None, max_length=100),
reference: str | None = Query(None, max_length=200), reference: str | None = Query(None, max_length=200),
research_id: 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), q: str = Query("", max_length=200),
sort: Literal["name", "created_at"] = "created_at", sort: Literal["name", "created_at"] = "created_at",
direction: Literal["asc", "desc"] = "desc", direction: Literal["asc", "desc"] = "desc",
): ):
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Business(db).backtests.runs( 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 def subset(preview_id: str, body: SubsetInput, request: Request):
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
return await Business(db).backtests.subset(preview_id, body) 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
+11 -3
View File
@@ -448,7 +448,7 @@ class BacktestLane:
def safe_progress(self, value): def safe_progress(self, value):
# Store useful protocol evidence, never arbitrary upstream diagnostics or credentials. # 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") message = value.get("error") or value.get("message")
if isinstance(message, str): if isinstance(message, str):
for secret in list(self.client.credentials or ()) + list(self.client.client.cookies.values()): for secret in list(self.client.credentials or ()) + list(self.client.client.cookies.values()):
@@ -485,11 +485,13 @@ class BacktestLane:
matched = [ matched = [
i i
for i in items 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 isinstance(settings, dict)
and all(k in settings and settings[k] == v for k, v in i.settings.items()) 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 = ( matched = (
items items
if (expression == items[0].expression or (not expression and detail is None)) if (expression == items[0].expression or (not expression and detail is None))
@@ -499,6 +501,9 @@ class BacktestLane:
) )
else [] 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. # Identical inputs within a multi-submit are intentionally not position-matched.
if len(matched) != 1 or (matched[0].simulation_id not in (None, child)): if len(matched) != 1 or (matched[0].simulation_id not in (None, child)):
return return
@@ -513,6 +518,9 @@ class BacktestLane:
if not await db.get(BacktestResult, item.id): if not await db.get(BacktestResult, item.id):
from datetime import datetime 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( db.add(
BacktestResult( BacktestResult(
item_id=item.id, item_id=item.id,
+15 -4
View File
@@ -93,7 +93,7 @@ class Backtests:
async def capabilities(self): async def capabilities(self):
return { return {
"alpha_types": ["REGULAR"], "alpha_types": ["REGULAR", "SUPER"],
"languages": ["FASTEXPR"], "languages": ["FASTEXPR"],
"instrument_types": ["EQUITY"], "instrument_types": ["EQUITY"],
"settings_schema": Candidate.model_json_schema(), "settings_schema": Candidate.model_json_schema(),
@@ -111,6 +111,8 @@ class Backtests:
from ..research.expressions import analyze from ..research.expressions import analyze
if not body.preparation_refs and not body.input_ids: if not body.preparation_refs and not body.input_ids:
return return
if any(c.alpha_type == "SUPER" for c in body.candidates):
raise HTTPException(422, "SUPER 组件快照不能使用字段数据准备集合")
await Preparations(self.db).bind(body) await Preparations(self.db).bind(body)
snapshots = [await Preparations(self.db).snapshot(i) for i in body.input_ids] snapshots = [await Preparations(self.db).snapshot(i) for i in body.input_ids]
for candidate in body.candidates: for candidate in body.candidates:
@@ -221,6 +223,8 @@ class Backtests:
if len(candidates) != len(selection): if len(candidates) != len(selection):
raise HTTPException(422, "选择包含不属于当前草稿的候选") raise HTTPException(422, "选择包含不属于当前草稿的候选")
data = {"name": draft.name, "source": draft.source, "candidates": candidates} 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"] candidates = DraftInput.model_validate(data).model_dump(mode="json")["candidates"]
config = await self.db.get(BacktestConfig, 1) config = await self.db.get(BacktestConfig, 1)
groups = defaultdict(list) groups = defaultdict(list)
@@ -253,6 +257,9 @@ class Backtests:
for indices in groups.values(): for indices in groups.values():
local_batches = [] local_batches = []
for index in indices: for index in indices:
if candidates[index]["alpha_type"] == "SUPER":
batches.append([index])
continue
batch = next( batch = next(
( (
b b
@@ -352,6 +359,7 @@ class Backtests:
ordinal=i, ordinal=i,
client_item_id=c.client_item_id, client_item_id=c.client_item_id,
expression=c.expression, expression=c.expression,
alpha_type=c.alpha_type, selection=c.selection, combo=c.combo,
settings=c.settings.model_dump(), settings=c.settings.model_dump(),
fingerprint=fingerprint(c.platform_input()), fingerprint=fingerprint(c.platform_input()),
) )
@@ -360,8 +368,10 @@ class Backtests:
await self.db.flush() await self.db.flush()
return await self.run(run.id) 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) query = select(BacktestRun)
if alpha_type:
query = query.where(BacktestRun.id.in_(select(BacktestItem.run_id).where(BacktestItem.alpha_type == alpha_type)))
if q: if q:
query = query.where(BacktestRun.name.contains(q, autoescape=True)) query = query.where(BacktestRun.name.contains(q, autoescape=True))
column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort] column = {"name": BacktestRun.name, "created_at": BacktestRun.created_at}[sort]
@@ -460,7 +470,7 @@ class Backtests:
for k in ( for k in (
"id", "id",
"client_item_id", "client_item_id",
"expression", "expression", "alpha_type", "selection", "combo",
"settings", "settings",
"attempt_id", "attempt_id",
"platform_status", "platform_status",
@@ -610,7 +620,8 @@ class Backtests:
source=Source.model_validate({**run.source, "parent_run_id": run.id}), source=Source.model_validate({**run.source, "parent_run_id": run.id}),
candidates=[ candidates=[
Candidate( 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 for r in selected
], ],
+9 -7
View File
@@ -91,27 +91,29 @@ class Business:
else None, 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 = {} result = {}
for key in ("region", "universe", "alpha_type", "language", "status", "stage"): for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
column = getattr(Alpha, key) column = getattr(Alpha, key)
result[key] = list( result[key] = list(
( (
await self.db.scalars( 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() ).all()
) )
result["tags"] = list( 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( 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( 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 return result
+6 -1
View File
@@ -66,6 +66,7 @@ def setting_rows(data):
for key in ( for key in (
"decay", "truncation", "pasteurization", "unitHandling", "decay", "truncation", "pasteurization", "unitHandling",
"nanHandling", "language", "visualization", "maxTrade", "maxPosition", "nanHandling", "language", "visualization", "maxTrade", "maxPosition",
"selectionHandling", "selectionLimit", "componentActivation",
): ):
definition = children.get(key) definition = children.get(key)
if not isinstance(definition, dict): if not isinstance(definition, dict):
@@ -200,11 +201,15 @@ class ResearchMetadata:
raise HTTPException(502, "算子分页提前结束") raise HTTPException(502, "算子分页提前结束")
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") snapshot = await self.get("operators")
notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))} notes = {r.name: r for r in await self.db.scalars(select(OperatorNote))}
rows = [] rows = []
for item in snapshot["content"].get("items", []): 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"]) note = notes.get(item["name"])
if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or ( if q.lower() not in json.dumps(item, ensure_ascii=False).lower() or (
category and item["category"] != category category and item["category"] != category
+2 -1
View File
@@ -18,11 +18,12 @@ async def operators(
q: str = "", q: str = "",
category: str | None = None, category: str | None = None,
favorite: bool = False, favorite: bool = False,
stage: str | None = None,
limit: int = Query(25, ge=1, le=100), limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
async with request.app.state.sessions() as db: 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") @router.post("/operators/refresh")
+5 -1
View File
@@ -265,6 +265,10 @@ class Runner:
await sync_catalog(self, job_id, payload) await sync_catalog(self, job_id, payload)
elif kind in ("full_sync", "daily_sync"): elif kind in ("full_sync", "daily_sync"):
await self.sync_all(job_id) 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": elif kind == "submission_check":
from .submission import run_check from .submission import run_check
@@ -302,7 +306,7 @@ class Runner:
await self.checkpoint( await self.checkpoint(
job_id, 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), "error": str(exc),
"next_retry_at": None, "next_retry_at": None,
}, },
+5 -3
View File
@@ -6,7 +6,7 @@ import io
import time import time
from collections import defaultdict from collections import defaultdict
from contextlib import AsyncExitStack, asynccontextmanager 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 import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.exceptions import RequestValidationError 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 .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
from .submission import router as submission_router from .submission import router as submission_router
from .superalpha.routes import router as superalpha_router
def account_output(account, client, settings): 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) return await Business(db).search_alphas(filters)
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"]) @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: async with sessions() as db:
return await Business(db).get_alpha_facets() return await Business(db).get_alpha_facets(management_scope)
@api.get( @api.get(
"/alphas/export", "/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(dashboard_router)
app.include_router(home_information_router) app.include_router(home_information_router)
app.include_router(backtest_router) app.include_router(backtest_router)
app.include_router(superalpha_router)
app.include_router(api) app.include_router(api)
app.include_router(catalog_router) app.include_router(catalog_router)
app.include_router(preparations_router) app.include_router(preparations_router)
+23 -7
View File
@@ -18,12 +18,28 @@ from ..models import MCPAudit, now
from ..research.serialization import encode_snapshot from ..research.serialization import encode_snapshot
from ..research_access import contracts as c from ..research_access import contracts as c
from ..research_access.service import ResearchAccess, ResearchError 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. # Name, schema, business method, required scope, description. No generic arbitrary HTTP tool.
TOOLS = { 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", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"), "search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"), "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。"), "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。"), "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 的进度,不发起认证;人工验证在网页完成。"), "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 读结果。"), "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 状态;无缓存不自动检查,结果不等同于平台提交资格。"), "get_self_correlation": (c.SelfCorrelationReference, "self_correlation", "research:read", "读取指定 Alpha 最新的本地自相关缓存,包括最大相关系数、样本覆盖与 stale 状态;无缓存不自动检查,结果不等同于平台提交资格。"),
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"), "search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 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": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"), "get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"), "get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
@@ -57,7 +73,7 @@ class MCPResearchServer:
self.mutation_lock = asyncio.Lock() self.mutation_lock = asyncio.Lock()
self.server = Server("wq-alpha-research", version="1.0.0", on_list_tools=self.list_tools, self.server = Server("wq-alpha-research", version="1.0.0", on_list_tools=self.list_tools,
on_call_tool=self.call_tool, on_call_tool=self.call_tool,
instructions="自由探索,直接固定候选回测,无需先建研究资产。工具不安排定时研究;结果按运行 ID 查询。") instructions="自由探索,直接固定候选回测,无需先建研究资产。使用已有模板时,expand_research_template 生成候选,get_template_candidates 分页核对,获得用户授权后 start_template_backtest 执行;无需额外预览或结果评估步骤。工具不安排定时研究;结果按运行 ID 查询。")
from urllib.parse import urlsplit from urllib.parse import urlsplit
host = urlsplit(settings.public_origin).netloc host = urlsplit(settings.public_origin).netloc
@@ -72,8 +88,8 @@ class MCPResearchServer:
return types.ListToolsResult(tools=[types.Tool(name=name, description=description, return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations( inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
readOnlyHint=scope == "research:read", destructiveHint=method == "control", readOnlyHint=scope == "research:read", destructiveHint=method == "control",
idempotentHint=method in {"submit", "control", "create_template"} or scope == "research:read", 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"})) 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() for name, (schema, method, scope, description) in TOOLS.items()
if scope in principal.scopes and "research:read" in principal.scopes]) if scope in principal.scopes and "research:read" in principal.scopes])
@@ -101,7 +117,7 @@ class MCPResearchServer:
try: try:
async with db.begin_nested(): async with db.begin_nested():
args = schema.model_validate(arguments) 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 = encode_snapshot(await getattr(access, method)(args))
data.setdefault("_meta", {"schema_version": 1, "observed_at": now().isoformat(), data.setdefault("_meta", {"schema_version": 1, "observed_at": now().isoformat(),
"nulls": "null 表示来源未提供,不等于零", "source": "system"}) "nulls": "null 表示来源未提供,不等于零", "source": "system"})
@@ -126,7 +142,7 @@ class MCPResearchServer:
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data} data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name, db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
request_id=fingerprint({"request_id": request_id}), input_digest=digest, 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))) result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
if not error: if not error:
if access.wake == "backtests": if access.wake == "backtests":
+22
View File
@@ -301,6 +301,9 @@ class BacktestItem(Base):
client_item_id: Mapped[str] = mapped_column(String(100)) client_item_id: Mapped[str] = mapped_column(String(100))
ordinal: Mapped[int] = mapped_column(Integer) ordinal: Mapped[int] = mapped_column(Integer)
expression: Mapped[str] = mapped_column(Text) 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) settings: Mapped[dict] = mapped_column(JSON)
fingerprint: Mapped[str] = mapped_column(String(64), index=True) fingerprint: Mapped[str] = mapped_column(String(64), index=True)
platform_status: Mapped[str] = mapped_column(String(30), default="pending") 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) 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): class BacktestEvent(Base):
__tablename__ = "backtest_events" __tablename__ = "backtest_events"
run_id: Mapped[str] = mapped_column(ForeignKey("backtest_runs.id"), primary_key=True) run_id: Mapped[str] = mapped_column(ForeignKey("backtest_runs.id"), primary_key=True)
+80
View File
@@ -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()))
+3
View File
@@ -51,7 +51,10 @@ class Assets:
} }
async def save(self, body, asset_id=None, provenance=None): async def save(self, body, asset_id=None, provenance=None):
from ..superalpha.contracts import PlanSpec
schema = { schema = {
"superalpha_plan": PlanSpec,
"template": TemplateSpec, "template": TemplateSpec,
"feature": FeatureSpec, "feature": FeatureSpec,
"view": ViewSpec, "view": ViewSpec,
+135 -26
View File
@@ -7,14 +7,22 @@ from collections import defaultdict
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select, update 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 ..backtests.service import Backtests, uid
from ..catalog.research_metadata import ResearchMetadata from ..catalog.research_metadata import ResearchMetadata
from ..catalog.service import Catalog 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 ..preparations.service import Preparations
from .assets import Assets 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 .serialization import encode_snapshot as jsonable_encoder
from .workspace_contracts import TemplateSpec from .workspace_contracts import TemplateSpec
@@ -45,7 +53,7 @@ class Experiments:
self.catalog = Catalog(db) self.catalog = Catalog(db)
self.assets = Assets(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): if len(set(ids)) != len(ids):
raise HTTPException(422, "输入快照重复") raise HTTPException(422, "输入快照重复")
snapshots = [await self.catalog.input(input_id) for input_id in ids] 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(): for name, kind in item["field_types"].items():
if name not in item["field_ids"]: if name not in item["field_ids"]:
continue 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} 在不同快照中类型不一致") raise HTTPException(422, f"字段 {name} 在不同快照中类型不一致")
fields[name] = kind fields[name] = kind
return snapshots, fields 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 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 template = TemplateSpec.model_validate(asset["content"]) if asset else body.template
scope = scope_of(body.settings) scope = scope_of(body.settings)
if template.scope and template.scope.model_dump() != scope: snapshots, fields = await self.inputs(body.input_ids, scope, check_types=kind != "template")
raise HTTPException(422, "模板适用范围与候选设置不同") if kind == "template" and not snapshots:
snapshots, fields = await self.inputs(body.input_ids, scope) raise HTTPException(422, "请先选择数据准备")
parents = ( parents = (
parent_snapshots parent_snapshots
if parent_snapshots is not None if parent_snapshots is not None
@@ -148,31 +156,53 @@ class Experiments:
) )
variables = {} variables = {}
for name, variable in template.variables.items(): 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": if variable.kind == "field":
for value in variable.values: if not values:
if fields.get(str(value)) != variable.field_type: 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} 不在固定输入中或类型不符") 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 str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values
): ):
raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实") raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实")
if not values:
raise HTTPException(422, f"变量 {name} 缺少候选取值,请通过模板接口补充,或将固定参数直接写入表达式")
variables[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: try:
expanded = expand(template.expression, variables, body.mode, body.limit, body.seed) expanded = expand(template.expression, variables, body.mode, body.limit, body.seed)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) from None raise HTTPException(422, str(exc)) from None
validation_evidence = {}
if kind != "template":
operators_snapshot = await ResearchMetadata(self.db).get("operators") operators_snapshot = await ResearchMetadata(self.db).get("operators")
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])} operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
setting_errors, settings_snapshot = await self.settings_check(body.settings) setting_errors, settings_snapshot = await self.settings_check(body.settings)
availability = await self.field_evidence(scope, fields) availability = await self.field_evidence(scope, fields)
validation_evidence = {
"field_availability": availability,
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
"operators_snapshot": operators_snapshot,
"settings_snapshot": settings_snapshot,
}
candidates = [] candidates = []
for index, item in enumerate(expanded["items"]): for index, item in enumerate(expanded["items"]):
findings = {}
if kind == "template":
self.check_syntax(item["expression"], f"候选 {index + 1}")
else:
validation = self.validate(item["expression"], fields, operators, scope, availability) validation = self.validate(item["expression"], fields, operators, scope, availability)
validation["availability"].extend(setting_errors) validation["availability"].extend(setting_errors)
if setting_errors and validation["status"] == "valid": if setting_errors and validation["status"] == "valid":
validation["status"] = "needs_review" validation["status"] = "needs_review"
findings["validation"] = validation
candidates.append( candidates.append(
{ {
**Candidate( **Candidate(
@@ -180,7 +210,7 @@ class Experiments:
).model_dump(mode="json"), ).model_dump(mode="json"),
"bindings": item["bindings"], "bindings": item["bindings"],
"input_ids": list(body.input_ids), "input_ids": list(body.input_ids),
"validation": validation, **findings,
"changes": [ "changes": [
self.diff(parent.get("expression", ""), item["expression"]) self.diff(parent.get("expression", ""), item["expression"])
for parent in parents for parent in parents
@@ -190,16 +220,21 @@ class Experiments:
) )
evidence = { evidence = {
"template": asset or {"content": template.model_dump(mode="json")}, "template": asset or {"content": template.model_dump(mode="json")},
"field_availability": availability,
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
"combination_count": expanded["combination_count"], "combination_count": expanded["combination_count"],
"seed": expanded["seed"], "seed": expanded["seed"],
"operators_snapshot": operators_snapshot, **validation_evidence,
"settings_snapshot": settings_snapshot,
**(extra_evidence or {}), **(extra_evidence or {}),
} }
return await self.save(template.name, kind, body.hypothesis, snapshots, parents, candidates, evidence) 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 @staticmethod
def diff(before, after): def diff(before, after):
return [ 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): async def archive(self, experiment_id):
"""Hide an immutable experiment; backtests and lineage must still resolve it. """Hide an immutable experiment; backtests and lineage must still resolve it.
@@ -297,7 +352,8 @@ class Experiments:
raise HTTPException(404, "研究实验不存在") raise HTTPException(404, "研究实验不存在")
return {"ok": True} 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) experiment = await self.get(experiment_id)
candidates = experiment["candidates"] candidates = experiment["candidates"]
if candidate_ids is not None: if candidate_ids is not None:
@@ -307,14 +363,21 @@ class Experiments:
candidates = [item for item in candidates if item["client_item_id"] in chosen] candidates = [item for item in candidates if item["client_item_id"] in chosen]
if len(candidates) != len(chosen): if len(candidates) != len(chosen):
raise HTTPException(422, "选择包含未知候选") raise HTTPException(422, "选择包含未知候选")
else: elif experiment["kind"] != "template":
candidates = [item for item in candidates if item["validation"]["status"] == "valid"] 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, "候选存在语法、类型或可用性问题,请先解决;至少保留一条已核实候选") raise HTTPException(422, "候选存在语法、类型或可用性问题,请先解决;至少保留一条已核实候选")
inputs = experiment["inputs"] inputs = experiment["inputs"]
return await Backtests(self.db).preview( if experiment["kind"] == "template":
PreviewInput( # Historical collections follow the same syntax/scope contract; old row findings are irrelevant.
inline=DraftInput( 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"], name=experiment["name"],
source=Source( source=Source(
kind=source_kind or experiment["kind"], kind=source_kind or experiment["kind"],
@@ -334,9 +397,55 @@ class Experiments:
for item in candidates for item in candidates
], ],
) )
),
preserve_source=True, 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"): async def setting_variants(self, body, *, parent_snapshot=None, extra_evidence=None, kind="variant"):
parents = ( parents = (
+10
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..backtests.contracts import RunOutput
from ..security import require_auth from ..security import require_auth
from .assets import Assets from .assets import Assets
from .comparisons import compare from .comparisons import compare
@@ -22,6 +23,7 @@ from .workspace_contracts import (
ImportCommit, ImportCommit,
ImportPreview, ImportPreview,
SettingVariants, SettingVariants,
TemplateBacktest,
WorkflowSpec, 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) 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) @router.post("/variants/settings", status_code=201)
async def settings_variants(body: SettingVariants, request: Request): async def settings_variants(body: SettingVariants, request: Request):
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
+2 -1
View File
@@ -522,7 +522,8 @@ class ResearchRuntime:
"type": "candidates", "type": "candidates",
"experiment_id": experiment["id"], "experiment_id": experiment["id"],
"candidate_ids": [ "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"] ids = step.output["candidate_ids"]
+8 -3
View File
@@ -11,12 +11,13 @@ from ..preparations.contracts import PreparationReference
from ..schemas import Contract from ..schemas import Contract
from .expressions import IDENTIFIER, PLACEHOLDER, normalize_template from .expressions import IDENTIFIER, PLACEHOLDER, normalize_template
AssetKind = Literal["template", "feature", "view", "workflow"] AssetKind = Literal["template", "feature", "view", "workflow", "superalpha_plan"]
class Variable(Contract): class Variable(Contract):
kind: Literal["field", "operator", "integer", "number", "group", "string", "fragment"] 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 field_type: Literal["MATRIX", "VECTOR", "GROUP"] | None = None
@model_validator(mode="after") @model_validator(mode="after")
@@ -44,7 +45,6 @@ class TemplateSpec(Contract):
description: str = Field(default="", max_length=10000) description: str = Field(default="", max_length=10000)
expression: str = Field(min_length=1, max_length=20000) expression: str = Field(min_length=1, max_length=20000)
variables: dict[str, Variable] = Field(default_factory=dict, max_length=100) variables: dict[str, Variable] = Field(default_factory=dict, max_length=100)
scope: Scope | None = None
category: Literal["template", "fragment"] = "template" category: Literal["template", "fragment"] = "template"
@field_validator("expression") @field_validator("expression")
@@ -144,6 +144,11 @@ class ExperimentPreview(Contract):
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000) 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): class EvaluationRules(Contract):
version: Literal["research-v1"] = "research-v1" version: Literal["research-v1"] = "research-v1"
sharpe_min: float = Field(default=1.0, allow_inf_nan=False) sharpe_min: float = Field(default=1.0, allow_inf_nan=False)
+87 -4
View File
@@ -1,15 +1,25 @@
"""Research capabilities use the same versioned assets and experiment services as HTTP.""" """Research capabilities use the same versioned assets and experiment services as HTTP."""
from fastapi import HTTPException
from pydantic import Field from pydantic import Field
from ..ai.capabilities import Capability from ..ai.capabilities import Capability
from ..backtests.ai_tools import wake_backtests
from ..catalog.research_metadata import ResearchMetadata from ..catalog.research_metadata import ResearchMetadata
from ..schemas import Contract from ..schemas import Contract
from .assets import Assets from .assets import Assets
from .evaluations import Evaluations from .evaluations import Evaluations
from .experiments import Experiments from .experiments import Experiments
from .features import Features 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): class AssetQuery(Contract):
@@ -33,6 +43,10 @@ class FeatureWrite(Contract):
version: int | None = Field(default=None, ge=1) version: int | None = Field(default=None, ge=1)
class TemplateVersionWrite(FixedAssetReference):
content: TemplateSpec
class ExperimentReference(Contract): class ExperimentReference(Contract):
experiment_id: str = Field(min_length=1, max_length=36) 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) 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): async def expand(ctx, args):
kind = "variant" if args.parent_alpha_ids or args.parent_experiment_ids else "template" kind = "variant" if args.parent_alpha_ids or args.parent_experiment_ids else "template"
return await Experiments(ctx.business.db).create( 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 = ( CAPABILITIES = (
Capability( Capability(
name="search_research_templates", name="search_research_templates",
@@ -68,6 +107,29 @@ CAPABILITIES = (
effect="query", effect="query",
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "template"), 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( Capability(
name="search_research_operators", name="search_research_operators",
schema=AssetQuery, schema=AssetQuery,
@@ -80,12 +142,33 @@ CAPABILITIES = (
Capability( Capability(
name="expand_research_template", name="expand_research_template",
schema=Expansion, schema=Expansion,
description="从固定输入和模板版本或内联模板保存不可变候选实验。包含分层校验,随机采样有数量上限,不开始回测。", description="从固定输入和模板版本或内联模板保存不可变候选实验。模板仅检查语法和数据准备与回测参数组合一致性;变体保留原校验。随机采样有数量上限,不开始回测。",
label="展开模板候选", label="展开模板候选",
renderer="research", renderer="research",
effect="prepare", effect="prepare",
handler=expand, 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( Capability(
name="prepare_setting_variants", name="prepare_setting_variants",
schema=SettingVariants, schema=SettingVariants,
@@ -107,7 +190,7 @@ CAPABILITIES = (
Capability( Capability(
name="prepare_experiment_backtest", name="prepare_experiment_backtest",
schema=CandidatePreview, schema=CandidatePreview,
description="从实验内已校验的固定候选保存回测确认预览,不启动模拟。", description="为变体等研究实验保存回测预览,不启动模拟;模板直接使用 start_template_backtest。",
label="准备研究回测", label="准备研究回测",
renderer="backtest", renderer="backtest",
effect="prepare", effect="prepare",
+70 -6
View File
@@ -5,7 +5,7 @@ from typing import Annotated, Literal
from pydantic import Field, model_validator 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 ..catalog.contracts import CatalogFilters, Scope
from ..preparations.contracts import PreparationReference from ..preparations.contracts import PreparationReference
from ..research.workspace_contracts import TemplateSpec from ..research.workspace_contracts import TemplateSpec
@@ -20,6 +20,12 @@ class Empty(Contract):
pass 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): class Authentication(Contract):
action: Literal["connect", "verify"] = "connect" action: Literal["connect", "verify"] = "connect"
@@ -44,11 +50,26 @@ class CompleteSettings(SimulationSettings):
model_config = {"json_schema_extra": {"required": list(SimulationSettings.model_fields)}} 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): class DirectCandidate(Candidate):
settings: CompleteSettings settings: CompleteSuperSettings | CompleteSettings
class Provenance(Contract): 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) reference: str | None = Field(default=None, max_length=200)
batch_id: 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) hypothesis: str | None = Field(default=None, max_length=2000)
@@ -95,7 +116,7 @@ class Control(Contract):
class CreateTemplate(Contract): class CreateTemplate(Contract):
template: TemplateSpec template: TemplateSpec
hypothesis: str = Field(min_length=1, max_length=10000) 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) reference: str | None = Field(default=None, max_length=200)
idempotency_key: Identifier idempotency_key: Identifier
@@ -112,6 +133,41 @@ class CreateTemplate(Contract):
return self 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): class CatalogSearch(Contract):
filters: CatalogFilters filters: CatalogFilters
dataset_id: str | None = Field(default=None, min_length=1, max_length=200) dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
@@ -123,12 +179,18 @@ class Scopes(Contract):
class SettingOptions(Page): class SettingOptions(Page):
kind: Literal["settings"] kind: Literal["settings"]
alpha_type: Literal["REGULAR", "SUPER"] = "REGULAR"
class Operators(Page): class Operators(Page):
kind: Literal["operators"] kind: Literal["operators"]
q: str = Field(default="", max_length=300) q: str = Field(default="", max_length=300)
category: str | None = None category: str | None = None
stage: Literal["REGULAR", "SELECTION", "COMBO"] | None = None
class SuperMetadata(Contract):
kind: Literal["superalpha"]
class Availability(Contract): class Availability(Contract):
@@ -138,7 +200,7 @@ class Availability(Contract):
class Metadata(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): class CatalogRefresh(Contract):
@@ -186,6 +248,8 @@ class SubmissionCheck(Contract):
class History(Page): 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) source: str | None = Field(default=None, max_length=100)
reference: str | None = Field(default=None, max_length=200) reference: str | None = Field(default=None, max_length=200)
status: str | None = Field(default=None, max_length=30) status: str | None = Field(default=None, max_length=30)
@@ -218,13 +282,13 @@ class Results(Page):
class Artifact(Page): class Artifact(Page):
item_id: RunId item_id: RunId
kind: Literal["snapshot", "pnl"] kind: Literal["snapshot", "pnl", "components"]
date_from: date | None = None date_from: date | None = None
date_to: date | None = None date_to: date | None = None
@model_validator(mode="after") @model_validator(mode="after")
def dates(self): 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") raise ValueError("日期筛选仅用于 PnL")
if self.date_from and self.date_to and self.date_from > self.date_to: if self.date_from and self.date_to and self.date_from > self.date_to:
raise ValueError("起始日期不能晚于结束日期") raise ValueError("起始日期不能晚于结束日期")
+41
View File
@@ -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
+9 -4
View File
@@ -2,7 +2,7 @@
from collections import Counter from collections import Counter
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from ..alphas import number, sanitize from ..alphas import number, sanitize
from ..backtests.contracts import fingerprint from ..backtests.contracts import fingerprint
@@ -54,7 +54,7 @@ def item_summary(item, result):
("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")} ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown")}
return encode_snapshot({ return encode_snapshot({
**{k: getattr(item, k) for k in ( **{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", "platform_status", "collection_status", "persistence_status", "simulation_id", "alpha_id",
)}, )},
"error": sanitize(item.error), "metrics": metrics, "error": sanitize(item.error), "metrics": metrics,
@@ -73,10 +73,12 @@ class EvidenceQueries:
query = select(BacktestItem, BacktestResult, BacktestRun).join( query = select(BacktestItem, BacktestResult, BacktestRun).join(
BacktestRun, BacktestRun.id == BacktestItem.run_id BacktestRun, BacktestRun.id == BacktestItem.run_id
).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id) ).outerjoin(BacktestResult, BacktestResult.item_id == BacktestItem.id)
for key in ("source", "reference"): for key in ("source", "reference", "research_id"):
value = getattr(args, key) value = getattr(args, key)
if value is not None: if value is not None:
query = query.where(BacktestRun.source["kind" if key == "source" else key].as_string() == value) 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: if args.status:
query = query.where(BacktestRun.status == args.status) query = query.where(BacktestRun.status == args.status)
if args.created_from: if args.created_from:
@@ -89,7 +91,7 @@ class EvidenceQueries:
query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay) query = query.where(BacktestItem.settings["delay"].as_integer() == args.scope.delay)
if args.q: if args.q:
escaped = args.q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") 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 = {} matches = {}
if args.candidates: if args.candidates:
for c in args.candidates: for c in args.candidates:
@@ -120,6 +122,9 @@ class EvidenceQueries:
if not item: if not item:
raise ResearchError("NOT_FOUND", "候选不存在") raise ResearchError("NOT_FOUND", "候选不存在")
result = await self.db.get(BacktestResult, item.id) 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": if args.kind == "snapshot":
# Top-level entries retain complete nested values; no hidden string/list truncation. # 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 [] entries = [{"key": k, "value": v} for k, v in sanitize(result.snapshot).items()] if result else []
+121 -6
View File
@@ -25,6 +25,8 @@ from ..research.workspace_contracts import FieldAvailabilityInput
from ..schemas import JobInput from ..schemas import JobInput
from ..submission import CheckInput, correlation_allows_check, create_check_job, local_alpha, source from ..submission import CheckInput, correlation_allows_check, create_check_job, local_alpha, source
from ..submission import fingerprint as submission_fingerprint from ..submission import fingerprint as submission_fingerprint
from ..superalpha.access import SuperResearchAccess
from ..worldquant import WqError
from .contracts import DirectCandidate, History from .contracts import DirectCandidate, History
from .queries import EvidenceQueries, page from .queries import EvidenceQueries, page
@@ -36,7 +38,7 @@ class ResearchError(Exception):
"retry_after": retry_after, "affected_items": affected_items or []} "retry_after": retry_after, "affected_items": affected_items or []}
class ResearchAccess: class ResearchAccess(SuperResearchAccess):
def __init__(self, db, principal, client, public_origin): def __init__(self, db, principal, client, public_origin):
self.db, self.principal, self.client = db, principal, client self.db, self.principal, self.client = db, principal, client
self.public_origin = public_origin.rstrip("/") self.public_origin = public_origin.rstrip("/")
@@ -48,15 +50,44 @@ class ResearchAccess:
def run_url(self, run_id): def run_url(self, run_id):
return f"{self.public_origin}/#backtests?run_id={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): async def capabilities(self, args):
return {**await self.backtests.capabilities(), "max_candidates": 100, return {**await self.backtests.capabilities(), "max_candidates": 100,
"settings_schema": DirectCandidate.model_json_schema(), "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", "confirmation": "调用者须已获本批执行授权;直接提交后返回稳定运行 ID",
"duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes), "duplicate_policies": ["reject", "rerun"], "permissions": sorted(self.principal.scopes),
"metadata_only": True, "actual_platform_allowance": None, "metadata_only": True, "actual_platform_allowance": None,
"templates": { "templates": {
"create_with": "create_research_template", "required_scope": "research:write", "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, "source_items_with": "get_backtest_results", "starts_backtests": False,
"web_url": f"{self.public_origin}/#templates", "web_url": f"{self.public_origin}/#templates",
}, },
@@ -139,15 +170,23 @@ class ResearchAccess:
async def metadata(self, args): async def metadata(self, args):
q = args.query q = args.query
metadata = ResearchMetadata(self.db) 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": if q.kind == "scopes":
return {"source": "worldquant_platform", **await platform_options(self.client)} return {"source": "worldquant_platform", **await platform_options(self.client)}
if q.kind == "operators": 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", return {**data, "status": "available" if data["fetched_at"] else "not_cached",
"has_more": q.offset + len(data["items"]) < data["total"]} "has_more": q.offset + len(data["items"]) < data["total"]}
if q.kind == "settings": if q.kind == "settings":
data = await metadata.get("settings") data = await metadata.get("settings")
items = data["content"].get("items", []) 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", 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)} "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)) data = await metadata.get(availability_key(q.field_id, q.scope))
@@ -181,14 +220,16 @@ class ResearchAccess:
async def refresh_job(self, args): async def refresh_job(self, args):
job = await self.db.get(Job, args.job_id) job = await self.db.get(Job, args.job_id)
if not job or job.kind not in {"catalog_sync", "field_sync", "pnl_refresh", "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", "研究刷新任务不存在") raise ResearchError("NOT_FOUND", "研究刷新任务不存在")
result = await self.business.get_job_status(args.job_id) result = await self.business.get_job_status(args.job_id)
query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None)) query = select(JobItem).where(JobItem.job_id == job.id, JobItem.error.is_not(None))
total = await self.db.scalar(select(func.count()).select_from(query.subquery())) 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))) errors = list(await self.db.scalars(query.order_by(JobItem.alpha_id).limit(args.limit).offset(args.offset)))
result.pop("errors", None) 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)} "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): async def check_self_correlation(self, args):
@@ -250,6 +291,76 @@ class ResearchAccess:
return await self.remember("create_research_template", args, digest, result, return await self.remember("create_research_template", args, digest, result,
business_id=result["template_id"]) 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): async def previous(self, operation, args):
# PostgreSQL row lock is shared with HTTP start and catalog/job creation. # 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()) 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) invalid.append(c.client_item_id)
if invalid: if invalid:
raise ResearchError("UNSUPPORTED_SETTINGS", "已缓存平台设置不支持这些组合;可显式刷新后重试", affected_items=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): async def submit(self, args):
previous, digest = await self.previous("submit_backtests", args) previous, digest = await self.previous("submit_backtests", args)
+14 -6
View File
@@ -10,23 +10,27 @@ from ..research.workspace_contracts import AssetWrite
from .queries import item_summary from .queries import item_summary
async def create_template(db, args, principal): async def create_template(db, args, principal, *, asset_id=None):
"""Save a new asset inside the caller's account-locked, idempotent transaction. """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. Args contain the external model's TemplateSpec and local source item IDs.
Return the versioned asset and theoretical combination count. Raise Return the versioned asset and theoretical combination count. Raise
ResearchError for name conflicts or missing/incomplete research evidence. ResearchError for name conflicts or missing/incomplete research evidence.
Stored evidence proves provenance, not profitability or platform eligibility; Stored evidence proves provenance, not profitability or platform eligibility;
schema validation does not validate every expanded FASTEXPR combination. 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 from .service import ResearchError
existing = await db.scalar(select(ResearchAsset).where( existing = await db.scalar(select(ResearchAsset).where(
ResearchAsset.kind == "template", ResearchAsset.name == args.template.name, ResearchAsset.kind == "template", ResearchAsset.name == args.template.name,
ResearchAsset.id != asset_id if asset_id else True,
).order_by(ResearchAsset.id).limit(1)) ).order_by(ResearchAsset.id).limit(1))
if existing: if existing:
raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板", raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板",
affected_items=[{"template_id": existing.id, "version": existing.version}]) 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( rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin(
BacktestResult, BacktestResult.item_id == BacktestItem.id, BacktestResult, BacktestResult.item_id == BacktestItem.id,
).where(BacktestItem.id.in_(args.source_item_ids)))).all() ).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, "admin_id": principal.admin_id,
"source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids], "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( asset = await Assets(db).save(AssetWrite(
kind="template", content=args.template.model_dump(mode="json"), 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 { return {
**asset, "template_id": asset["id"], **asset, "template_id": asset["id"],
"combination_count": str(math.prod(len(v.values) for v in args.template.variables.values())), "combination_count": (str(math.prod(len(v.values) for v in args.template.variables.values()))
"validation": {"structure": "valid", "source_evidence": "recorded", 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"}, "expanded_candidates": "not_validated", "platform_semantics": "unknown"},
"next_step": "在模板工坊选择固定输入及模拟设置,展开并核验候选,再确认批量回测。", "next_step": "选择数据准备和回测参数,用 expand_research_template 生成候选集合;核对候选后按已获授权范围调用 start_template_backtest。也可在模板工坊完成。",
} }
+6 -1
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
Submission = Literal["UNSUBMITTED", "SUBMITTED"] 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[ SortField = Literal[
"id", "id",
"name", "name",
@@ -69,10 +69,12 @@ class PreferencesInput(Contract):
class AlphaFilters(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_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_min: float | None = Field(default=None, ge=-1, le=1)
local_correlation_max: 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 submission_blocked: bool | None = None
ppac_candidate: bool | None = None
submission: Submission | None = None submission: Submission | None = None
source: str | None = Field(default=None, max_length=100) source: str | None = Field(default=None, max_length=100)
source_reference: str | None = Field(default=None, max_length=200) source_reference: str | None = Field(default=None, max_length=200)
@@ -237,6 +239,9 @@ class AlphaSummary(BaseModel):
id: str id: str
name: str | None name: str | None
expression_preview: str expression_preview: str
selection_preview: str = ""
combo_preview: str = ""
component_count: int | None = None
alpha_type: str | None alpha_type: str | None
language: str | None language: str | None
stage: str | None stage: str | None
+1
View File
@@ -0,0 +1 @@
"""Super Alpha construction and immutable component evidence over shared execution."""
+43
View File
@@ -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}"}
+140
View File
@@ -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)
+97
View File
@@ -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": ["平台实际组件尚未核实"]}
+31
View File
@@ -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"]}
+27
View File
@@ -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": "结构校验与平台执行分开;缺少适用范围的算子保持未知"}
+135
View File
@@ -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"]
+227
View File
@@ -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)}
+45
View File
@@ -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 "仅按缓存校验,仍需平台执行验证"}
+8
View File
@@ -468,6 +468,14 @@ class WqClient:
"universe": scope.universe, "delay": scope.delay, "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): async def research_setting_options(self):
"""Snapshot full setting choices for constrained research, including neutralization.""" """Snapshot full setting choices for constrained research, including neutralization."""
return await self._read_json("OPTIONS", "/simulations") 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 -4
View File
@@ -16,10 +16,16 @@ class Platform:
self.detail_fail = False self.detail_fail = False
self.fail_child = None self.fail_child = None
self.missing = False 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" self.secret = "synthetic-platform-secret"
def __call__(self, request): def __call__(self, request):
path = request.url.path 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": if path == "/authentication":
return httpx.Response(201, json={}) return httpx.Response(201, json={})
if path == "/simulations" and request.method == "POST": if path == "/simulations" and request.method == "POST":
@@ -39,29 +45,35 @@ class Platform:
ids = [] ids = []
for i, item in enumerate(data): for i, item in enumerate(data):
child = parent if len(data) == 1 else f"{parent}c{i}" 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 = { progress = {
"status": "COMPLETE", "status": "COMPLETE",
"alpha": aid, "alpha": aid,
"regular": item["regular"], "regular": item.get("regular", ""),
"settings": item["settings"], "settings": item["settings"],
} }
if i == self.fail_child: if i == self.fail_child:
progress = { progress = {
"status": "FAILED", "status": "FAILED",
"regular": item["regular"], "regular": item.get("regular", ""),
"settings": item["settings"], "settings": item["settings"],
"message": "invalid expression", "message": "invalid expression",
} }
self.simulations[child] = progress self.simulations[child] = progress
self.alphas[aid] = { self.alphas[aid] = {
"id": aid, "id": aid,
"regular": {"code": item["regular"]}, "regular": {"code": item.get("regular", "")},
"type": "REGULAR", "type": "REGULAR",
"settings": item["settings"], "settings": item["settings"],
"is": {"sharpe": None, "fitness": 0.8}, "is": {"sharpe": None, "fitness": 0.8},
"status": "UNSUBMITTED", "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) ids.append(child)
if len(data) > 1: if len(data) > 1:
self.simulations[parent] = { self.simulations[parent] = {
+74
View File
@@ -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))
+4 -1
View File
@@ -162,7 +162,10 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
async with ClientSession(streams[0], streams[1]) as client: async with ClientSession(streams[0], streams[1]) as client:
await client.initialize() await client.initialize()
listed = await client.list_tools() listed = await client.list_tools()
assert len(listed.tools) == 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} assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
caps = await client.call_tool("get_research_capabilities", {}) caps = await client.call_tool("get_research_capabilities", {})
assert caps.structured_content["max_candidates"] == 100 assert caps.structured_content["max_candidates"] == 100
+92
View File
@@ -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)
+163 -3
View File
@@ -100,7 +100,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
assert "submit_backtests" not in listed assert "submit_backtests" not in listed
assert not tool.annotations.read_only_hint and not tool.annotations.destructive_hint 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 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 assert tool.input_schema["additionalProperties"] is False
caps = await client.call_tool("get_research_capabilities", {}) caps = await client.call_tool("get_research_capabilities", {})
assert caps.structured_content["templates"]["create_with"] == TOOL 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"]} == { 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] 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"] assert experiment["evidence"]["template"]["provenance"] == stored["provenance"]
async with app.state.sessions() as db: 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(BacktestRun)) == 1
@@ -169,7 +169,7 @@ async def test_template_invalid_inputs_and_missing_sources_are_atomic(app, compl
principal, _ = await credentials(app) principal, _ = await credentials(app)
valid = template_request(completed_source["id"]) valid = template_request(completed_source["id"])
variants = [ 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": " "}, {"source_item_ids": [completed_source["id"]] * 2}, {"hypothesis": " "},
{"force": True}, {"idempotency_key": ""}, {"force": True}, {"idempotency_key": ""},
{"template": valid["template"] | {"expression": "rank({missing})"}}, {"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: async with app.state.sessions() as db:
principal = await authenticate(db, response.json()["token"]) principal = await authenticate(db, response.json()["token"])
assert principal.scopes == scopes 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)
+108
View File
@@ -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&region=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"), json=construction("none"),
) )
).status_code == 403 ).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
+172 -19
View File
@@ -8,7 +8,7 @@ from app.catalog.research_metadata import ResearchMetadata
from app.models import BacktestRun, CatalogResource, ResearchExperiment from app.models import BacktestRun, CatalogResource, ResearchExperiment
from app.research.expressions import analyze, expand from app.research.expressions import analyze, expand
from tests.conftest import alpha 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 SCOPE, prepare, sync
from tests.test_catalog import catalog as catalog_fixture 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) 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()}) saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": template()})
assert saved.status_code == 201, saved.text assert saved.status_code == 201, saved.text
asset = saved.json() 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 assert generated.status_code == 201, generated.text
experiment = generated.json() experiment = generated.json()
assert len(experiment["candidates"]) == 2 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 = template()
modified["expression"] = "-rank({field})" modified["expression"] = "-rank({field})"
response = await logged_in.put( 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 ).status_code == 409
platform, lane = await setup(app) platform, lane = await setup(app)
preview = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={}) from app.models import BacktestPreview
assert preview.status_code == 201, preview.text
assert not platform.posts url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
run = await start(logged_in, preview.json(), "research-stage-one") 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"]) await execute(app, lane, run["backtest_run_id"])
results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json() results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
aid = results["items"][0]["alpha_id"] 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"]] 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 = expansion(research_input["id"])
body["template"]["variables"]["field"]["values"] = ["other_field"] body["template"]["variables"]["field"]["values"] = ["other_field"]
assert (await logged_in.post("/api/v1/research/experiments", json=body)).status_code == 422 body["template"]["expression"] = "made_up({field}) + vec_avg(TEST_FIN_001)"
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})"
response = await logged_in.post("/api/v1/research/experiments", json=body) response = await logged_in.post("/api/v1/research/experiments", json=body)
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
eid = response.json()["id"] experiment = response.json()
assert (await logged_in.post(f"/api/v1/research/experiments/{eid}/preview", json={})).status_code == 422 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: 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(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): async def test_import_preview_conflict_and_explicit_commit(logged_in):
legacy = { legacy = {
"name": "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: async with app.state.sessions.begin() as db:
await ResearchMetadata(db).publish( await ResearchMetadata(db).publish(
"availability-fixture", "availability-fixture",
@@ -382,12 +408,11 @@ async def test_published_input_does_not_override_conflicting_field_evidence(app,
experiment = ( experiment = (
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"])) await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
).json() ).json()
assert experiment["candidates"][0]["validation"]["status"] == "needs_review" assert all("validation" not in c for c in experiment["candidates"])
assert experiment["candidates"][1]["validation"]["status"] == "valid"
denied = await logged_in.post( denied = await logged_in.post(
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c1"]} 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( duplicate = await logged_in.post(
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c2", "c2"]} 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 assert response.status_code == 422 and "不能改变" in response.text
async with app.state.sessions() as db: async with app.state.sessions() as db:
assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted")) 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
+210
View File
@@ -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
View File
@@ -1,6 +1,17 @@
# Independent production configuration; do not merge with compose.yaml. # Independent production configuration; do not merge with compose.yaml.
name: wq-alpha-production 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 x-backend: &backend
image: wq-alpha-production-backend:${DEPLOY_TAG:-local} image: wq-alpha-production-backend:${DEPLOY_TAG:-local}
build: build:
@@ -21,13 +32,15 @@ x-backend: &backend
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
AI_TIMEOUT: ${AI_TIMEOUT:-180} AI_TIMEOUT: ${AI_TIMEOUT:-180}
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
networks: # Reach the remote PostgreSQL endpoint through the default network's egress.
- default
- database
services: services:
backend: backend:
<<: *backend <<: *backend
labels:
<<: *observability-labels
observability.service: "backend"
logging: *container-logging
# Migration is run separately with all application writers stopped. # 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] command: [uvicorn, 'app.main:create_app', --factory, --host, 0.0.0.0, --port, '8000', --workers, '1', --proxy-headers]
healthcheck: healthcheck:
@@ -40,10 +53,18 @@ services:
restart: unless-stopped restart: unless-stopped
migrate: migrate:
<<: *backend <<: *backend
labels:
<<: *observability-labels
observability.service: "migrate"
logging: *container-logging
profiles: [jobs] profiles: [jobs]
command: [alembic, upgrade, head] command: [alembic, upgrade, head]
restart: 'no' restart: 'no'
web: web:
labels:
<<: *observability-labels
observability.service: "web"
logging: *container-logging
image: wq-alpha-production-web:${DEPLOY_TAG:-local} image: wq-alpha-production-web:${DEPLOY_TAG:-local}
build: build:
context: . context: .
@@ -66,10 +87,6 @@ services:
start_period: 10s start_period: 10s
restart: unless-stopped restart: unless-stopped
networks:
database:
external: true
name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network}
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:
+29 -14
View File
@@ -1,6 +1,8 @@
# Gitea 生产部署 # 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. 填写配置和数据库账号密码 ## 1. 填写配置和数据库账号密码
@@ -8,22 +10,23 @@
| 位置 | 名称 | 填写内容 | | 位置 | 名称 | 填写内容 |
| --- | --- | --- | | --- | --- | --- |
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` | | Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@远程数据库域名或IP:端口/数据库名`,地址须能从 B 的应用容器访问 |
| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 | | Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 |
| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 | | Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 |
| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 | | Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 |
| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 | | Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 |
| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` |
| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` | | Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` | | Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` |
| Variables(可选) | `MCP_ENABLED` | 设置 `true` 启用 MCP;未配置时默认关闭 | | 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 ```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`。 `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`;修改管理员环境变量不会重置已有管理员密码。 如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。
@@ -42,19 +47,27 @@ WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面
## 2. 配置 Gitea Runner ## 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、基础镜像仓库和依赖源。 执行环境需要 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` 后重试。不要在部署进行时删除锁。 如果 Runner 被强制终止或 Docker 断连,可能留下锁容器。先确认没有本项目部署正在执行,再手动运行 `docker rm wq-alpha-production-deploy-lock` 后重试。不要在部署进行时删除锁。
## 3. 配置反向代理并首次运行 ## 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
bash scripts/deploy-production.sh 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 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。 固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。
从旧服务器切换时,若旧实例仍连接同一生产数据库,须在 B 上执行迁移前停止旧实例及其定时任务。脚本和部署锁只管理 B 上的容器,不会停止 A 上的旧应用,也不能协调两台服务器对同一数据库的迁移和写入。
首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。 首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。
## 4. 升级、备份和失败处理 ## 4. 升级、备份和失败处理
@@ -72,7 +87,7 @@ bash scripts/deploy-production.sh
如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。 如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。
以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开): 以下排查命令在 **B 服务器**运行,不依赖凭据环境变量(可能含业务信息的日志请勿公开):
```bash ```bash
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production 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 夜间全量目录同步 ## 5. 1Panel 夜间全量目录同步
在 1Panel 的计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围: 在 **B 服务器**的 1Panel 计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
```bash ```bash
docker exec wq-alpha-production-backend-1 \ docker exec wq-alpha-production-backend-1 \
+52 -2
View File
@@ -54,7 +54,7 @@ python -m app.cli mcp-token-revoke TOKEN_ID
| search_data_preparations | `{q?,scope_key?,limit?,offset?}`;查询可编辑集合及当前版本 | | search_data_preparations | `{q?,scope_key?,limit?,offset?}`;查询可编辑集合及当前版本 |
| get_data_preparation | `{id,version,q?,limit?,offset?}`;按版本分页预览字段与数据集归属,版本冲突重新选择 | | get_data_preparation | `{id,version,q?,limit?,offset?}`;按版本分页预览字段与数据集归属,版本冲突重新选择 |
| search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 | | search_catalog | `{filters:{region,universe,delay,...},dataset_id?}`;省略 dataset_id 查数据集,提供则查字段 |
| get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability | | get_research_metadata | `{query:{kind,...}}`;kind 为 scopes/settings/operators/field_availability/superalpha |
| refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl | | refresh_research_data | `{query:{kind,...}}`;kind 为 catalog/operators/settings/field_availability/pnl |
| get_submission_check | `{alpha_id}`;读取检查上下文、snapshot 和缓存结果,不发起检查 | | get_submission_check | `{alpha_id}`;读取检查上下文、snapshot 和缓存结果,不发起检查 |
| check_submission | `{alpha_id,snapshot,descriptions}`;写回已确认描述并异步检查,绝不正式提交;要求 research:refresh | | 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?}` | | submit_backtests | `{name,candidates,idempotency_key,preparation_refs?,duplicate_policy?,source?}` |
| get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 | | get_backtest | `{run_id,after?,event_limit?}`,after 为事件游标 |
| get_backtest_results | `{run_id,item_ids?,limit?,offset?}` | | 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}` | | 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 秒,成功只返回快照引用,完整内容用读取工具获取。失败不发布半成品。 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}`。提交时核对版本、范围和字段,固定独立快照并保存到回测来源;空集合或版本冲突不会创建运行。后续编辑或删除集合不影响回测。无需旧输入草稿接口。 使用数据准备集合时,`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 只是一项元数据,不告诉你缺失发生在哪几年,也不完整表达横截面质量;VECTOR 非空容器也可能没有有效元素。不要把社区的 40%/60%/80% 建议阈值当官方规则。检查逐年、逐区覆盖及事件时点,比固定门槛更重要。
来源:[【平台新功能】字段新特征:datecoverage的介绍、应用与勘误。](https://support.worldquantbrain.com/hc/en-us/community/posts/37581194344215)(发帖 2026-01-10,社区经验)。 来源:[【平台新功能】字段新特征: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. 当前优先级 ## 10. 当前优先级
已有九月四张卡覆盖 SNR、机构拥挤、Wikipedia 关注和订单流。新补充方向优先选择“极性配对、同行相对位置、明确口径的实际/预测偏差”中你能取得完整数据的少量假设。优先级属于本库研究建议,未实际运行或比较这些方向的收益。 已有九月四张卡覆盖 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,社区经验)。 来源:[[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 脚本。 本次没有安装或运行 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 口径也要单独核对。 可迁移的工程做法是先清点候选、小样本检查,再扩大批量;逐项记录源 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 日。可准备一个具体研究瓶颈和证据;本次没有访问预约服务、核对剩余名额或预约。 [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、数据、文档及论坛文章。本库记录公告及来源,不作协议效力或个案法律判断;本轮仅本地整理,没有对外发布、推送仓库或改变权限。 [关于第三方发布的论坛公告](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 也可重新评估;但不能只因最近几天排名好就重仓。 先排查数据中断与不可投资表现,再按同族/相关簇留代表,然后看同时回撤和成本后的组合增量。旧 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 月必须更新的输出格式 ## 9 月必须更新的输出格式
7 月中文教程仍写 ISIN|MIC 字典;最新官方提交页(9 月 6 日更新)要求**对象数组**。结构示意如下,尖括号为类型占位符,不能原样提交: 7 月中文教程仍写 ISIN|MIC 字典;9 月 7 日首次核查的官方提交页已要求**对象数组**。2026-09-18 再读该页(页面更新 9 月 13 日),要求仍为对象数组。结构示意如下,尖括号为类型占位符,不能原样提交:
```text ```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,社区经验)。 社区“信号 5–7 天衰减”来自特定经验,不是所有 SPC 机制的固定半衰期。期限应根据来源更新节奏与前瞻验证决定。来源:[【SPC实战】信号衰减5-7天:数据源“时效性”比“全面性”更重要](https://support.worldquantbrain.com/hc/en-us/community/posts/42955680180247)(发帖 2026-08-24,社区经验)。
本次未调用 LLM 生成投资组合,未提交 Prompt、调整权重或参加比赛。 本次未调用 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 是否由不可投资的极端持仓产生;同一模板换字段是否仍是同一风险来源;回填是否把旧事件变成持续仓位。 大规模搜索时特别检查:收益是否集中在某一年;低 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 或会话令牌,也未推广为全局规则或共享脚本。 任务专用采集入口和状态位于本机 `.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 采集通过;采集脚本语法检查通过,后续增量比较、失败恢复和限流退避尚未通过真实故障场景演练,不能将首次成功等同长期运行保证。 本轮验证:两个 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,6 +29,299 @@
"No source authentication material is included." "No source authentication material is included."
], ],
"latest_forum_sync": { "latest_forum_sync": {
"collected_at": "2026-09-25",
"timezone": "Asia/Shanghai",
"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": 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,
"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": [
"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", "collected_at": "2026-09-11",
"timezone": "Asia/Shanghai", "timezone": "Asia/Shanghai",
"started_at": "2026-09-11T01:03:40.336597+00:00", "started_at": "2026-09-11T01:03:40.336597+00:00",
@@ -77,5 +370,78 @@
"All scoped comments fetched, selective historical body reading; images and attachments not fully verified.", "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." "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)。 增加同类字段可能只是叠加相关噪声和缺失。帖子记录了同域克隆失败、跨类别组合更好的样本,但没有证明多类别组合必然有效。来源:[字段堆越多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)。 保持主字段、方向与覆盖语义后,才讨论删除冗余算子;降低 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)。 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 或分配点数。 关于低 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 或硬性提交规则。 本库是当前任务交付物,没有将这些社区经验自动写入全局 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)再次强调过提交检查不等于成本后质量、复杂度与多样性需要分开看。晋级与收入结果均为作者自报,不能将成功者经历称为保证可复制的方法,也不能把地区或数据类别数量直接当作有效分散度。
+2 -2
View File
@@ -1,8 +1,8 @@
# WorldQuant 研究重启知识库 # 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 消耗”当作研究进展。 **先更新研究方向,再恢复回测规模。** 当前最值得投入的是有经济解释的低相关信号、能保留失败证据的 AI 实验闭环,以及组合层的增量价值。不要把“提交数量”“Token 消耗”当作研究进展。
+79
View File
@@ -1,3 +1,4 @@
import { SuperAlphaResearchPage } from "./superalpha/SuperAlphaResearchPage";
import { FieldDirectory } from "./preparations/FieldDirectory"; import { FieldDirectory } from "./preparations/FieldDirectory";
import { DataPreparationPage } from "./preparations/DataPreparationPage"; import { DataPreparationPage } from "./preparations/DataPreparationPage";
import { SnapshotDialog } from "./preparations/SnapshotDialog"; import { SnapshotDialog } from "./preparations/SnapshotDialog";
@@ -66,6 +67,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if ( if (
[ [
"superalpha-research",
"operators", "operators",
"templates", "templates",
"variants", "variants",
@@ -101,6 +103,9 @@ export default function App() {
const [alphaContext, setAlphaContext] = useState<PageContext>({ const [alphaContext, setAlphaContext] = useState<PageContext>({
page: "alphas", page: "alphas",
}); });
const [superAlphaContext, setSuperAlphaContext] = useState<PageContext>({
page: "superalphas",
});
const [backtestContext, setBacktestContext] = useState<PageContext>({ const [backtestContext, setBacktestContext] = useState<PageContext>({
page: "backtests", page: "backtests",
}); });
@@ -252,7 +257,23 @@ export default function App() {
location.hash = next; location.hash = next;
setPage(next); setPage(next);
}; };
const navigationRequest = useRef(0);
const handleAction = (action: UIAction) => { 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); const destination = actionDestination(action);
if (!destination) return; if (!destination) return;
if (destination.chat === "open") setChatOpen(true); if (destination.chat === "open") setChatOpen(true);
@@ -261,6 +282,21 @@ export default function App() {
if (destination.page) changePage(destination.page); if (destination.page) changePage(destination.page);
setAIAction(action); 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 () => { const logout = async () => {
try { try {
await post("/auth/logout"); await post("/auth/logout");
@@ -439,6 +475,44 @@ export default function App() {
onOverlay={focusBusiness} onOverlay={focusBusiness}
/> />
</div> </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"}> <div className="backtest-page-view" hidden={page !== "backtests"}>
{visitedBacktests && ( {visitedBacktests && (
<BacktestPage <BacktestPage
@@ -572,6 +646,11 @@ export default function App() {
? researchContext ? researchContext
: { page: "variants" as const }, : { page: "variants" as const },
alphas: alphaContext, alphas: alphaContext,
superalphas: superAlphaContext,
"superalpha-research":
researchContext.page === "superalpha-research"
? researchContext
: { page: "superalpha-research" as const },
datasets: datasetContext, datasets: datasetContext,
fields: { page: "fields" as const }, fields: { page: "fields" as const },
preparations: { page: "preparations" as const }, preparations: { page: "preparations" as const },
+12 -2
View File
@@ -15,6 +15,8 @@ export type PageContext = {
page: page:
| "home" | "home"
| "alphas" | "alphas"
| "superalphas"
| "superalpha-research"
| "account" | "account"
| "datasets" | "datasets"
| "fields" | "fields"
@@ -48,9 +50,17 @@ export type PageContext = {
filters?: Record<string, unknown>; filters?: Record<string, unknown>;
}; };
export type AlphaUIAction = 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 }; | { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
export type UIAction = 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_feature"; asset_id: string; version?: number; nonce: number }
| { type: "open_template"; 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 } | { 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_research_input"; input_id: string; nonce: number }
| { type: "open_backtest"; run_id: string; nonce: number } | { type: "open_backtest"; run_id: string; nonce: number }
| { type: "open_backtest_preview"; preview_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 }; | { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
export type ToolCard = { export type ToolCard = {
id: string; id: string;
+13
View File
@@ -68,6 +68,10 @@ const contextLabels: Record<
home: () => "上下文:首页看板", home: () => "上下文:首页看板",
alphas: (context) => alphas: (context) =>
`上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`, `上下文:${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: () => "上下文:算子库", operators: () => "上下文:算子库",
templates: (context) => templates: (context) =>
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`, `上下文:模板工坊${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. // Exhaustive action destinations prevent a new action silently falling into Alpha.
const destinations: Record<UIAction["type"], Destination> = { const destinations: Record<UIAction["type"], Destination> = {
open_superalpha_research: { page: "superalpha-research", chat: "responsive" },
open_feature: { page: "features", chat: "responsive" }, open_feature: { page: "features", chat: "responsive" },
open_template: { page: "templates", chat: "responsive" }, open_template: { page: "templates", chat: "responsive" },
open_experiment: { 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 { 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) return Object.hasOwn(destinations, action.type)
? destinations[action.type] ? destinations[action.type]
: undefined; : undefined;
+1
View File
@@ -85,6 +85,7 @@ export const jobLabels: Record<string, string> = {
catalog_full_sync: "全量同步数据目录与字段", catalog_full_sync: "全量同步数据目录与字段",
catalog_sync: "同步数据集目录", catalog_sync: "同步数据集目录",
field_sync: "同步数据字段", field_sync: "同步数据字段",
super_selection_preview: "Super Alpha 组件预览",
full_sync: "全量同步 Alpha", full_sync: "全量同步 Alpha",
daily_sync: "按天同步 Alpha", daily_sync: "按天同步 Alpha",
self_correlation: "本地自相关检测", self_correlation: "本地自相关检测",
+66 -5
View File
@@ -1,3 +1,4 @@
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
import { import {
CatalogTableToolbar, CatalogTableToolbar,
CatalogIconAction, CatalogIconAction,
@@ -90,6 +91,7 @@ export function BacktestPage({
}) { }) {
const [view, setView] = useState("runs"); const [view, setView] = useState("runs");
const [sourceFilter, setSourceFilter] = useState(""); const [sourceFilter, setSourceFilter] = useState("");
const [alphaType, setAlphaType] = useState("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [filterDraft, setFilterDraft] = useState({ q: "", source: "" }); const [filterDraft, setFilterDraft] = useState({ q: "", source: "" });
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
@@ -180,7 +182,9 @@ export function BacktestPage({
offset: (page - 1) * pageSize, offset: (page - 1) * pageSize,
sort: sort.value, sort: sort.value,
direction: sort.direction, direction: sort.direction,
...(view === "runs" ? { source: sourceFilter } : {}), ...(view === "runs"
? { source: sourceFilter, alpha_type: alphaType }
: {}),
}); });
const [data, c, kinds] = await Promise.all([ const [data, c, kinds] = await Promise.all([
api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`), api<Page<Run> | Page<DraftSummary>>(`/backtests/${view}?${params}`),
@@ -199,7 +203,16 @@ export function BacktestPage({
} finally { } finally {
if (sequence === requestSequence.current) setLoading(false); 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(() => { useEffect(() => {
if (!active) return; if (!active) return;
let alive = true; let alive = true;
@@ -472,6 +485,24 @@ export function BacktestPage({
const drawerVisible = active && !suspended; const drawerVisible = active && !suspended;
return ( return (
<section className="backtest-page"> <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 <CatalogTableToolbar
label="回测研究" label="回测研究"
active={active && !suspended && !editor && !runId && !settingsDraft} active={active && !suspended && !editor && !runId && !settingsDraft}
@@ -723,8 +754,21 @@ export function BacktestPage({
{ {
title: "表达式", title: "表达式",
dataIndex: "expression", dataIndex: "expression",
width: 260, width: 320,
ellipsis: true, 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: "最终参数", title: "最终参数",
@@ -1002,12 +1046,14 @@ export function BacktestPage({
dataIndex: "expression", dataIndex: "expression",
width: 260, width: 260,
ellipsis: true, ellipsis: true,
render: (v, i) => ( render: (_, i) => (
<Button <Button
theme="borderless" theme="borderless"
onClick={() => setItemDetail(i!)} onClick={() => setItemDetail(i!)}
> >
{String(v)} {i!.alpha_type === "SUPER"
? `SUPER · ${i!.selection} · ${i!.combo}`
: i!.expression}
</Button> </Button>
), ),
}, },
@@ -1055,6 +1101,7 @@ export function BacktestPage({
onAction({ onAction({
type: "open_alpha", type: "open_alpha",
alpha_id: i!.alpha_id!, alpha_id: i!.alpha_id!,
alpha_type: i!.alpha_type,
nonce: Date.now(), nonce: Date.now(),
}) })
} }
@@ -1081,7 +1128,21 @@ export function BacktestPage({
收起候选详情 收起候选详情
</Button> </Button>
</div> </div>
{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> <p>{itemDetail.expression}</p>
)}
{itemDetail.error && ( {itemDetail.error && (
<Banner type="warning" description={itemDetail.error} /> <Banner type="warning" description={itemDetail.error} />
)} )}
+13 -1
View File
@@ -12,6 +12,9 @@ export type SimulationSettings = {
language: "FASTEXPR"; language: "FASTEXPR";
visualization: boolean; visualization: boolean;
maxTrade: "ON" | "OFF"; maxTrade: "ON" | "OFF";
selectionHandling?: "POSITIVE" | "NON_ZERO" | "NON_NAN";
selectionLimit?: number;
componentActivation?: "IS" | "OS";
maxPosition?: "ON" | "OFF"; maxPosition?: "ON" | "OFF";
}; };
export const initialSettings: SimulationSettings = { export const initialSettings: SimulationSettings = {
@@ -34,9 +37,15 @@ export type Candidate = {
client_item_id: string; client_item_id: string;
expression: string; expression: string;
settings: SimulationSettings; settings: SimulationSettings;
alpha_type?: "REGULAR"; alpha_type?: "REGULAR" | "SUPER";
selection?: string;
combo?: string;
}; };
export type Source = { export type Source = {
research_kind?: string | null;
superalpha_plan_id?: string | null;
superalpha_plan_version?: number | null;
selection_snapshot_ids?: string[];
kind: string; kind: string;
reference?: string | null; reference?: string | null;
batch_id?: string | null; batch_id?: string | null;
@@ -102,6 +111,9 @@ export type Run = {
scheduler: Scheduler; scheduler: Scheduler;
}; };
export type Item = { export type Item = {
alpha_type: "REGULAR" | "SUPER";
selection?: string;
combo?: string;
id: string; id: string;
client_item_id: string; client_item_id: string;
expression: string; expression: string;
+65
View File
@@ -1,3 +1,4 @@
import { ComponentsPanel } from "../superalpha/ComponentsPanel";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { formatAlphaMetric } from "../alphaMetrics"; import { formatAlphaMetric } from "../alphaMetrics";
import { import {
@@ -65,6 +66,7 @@ export function AlphaDetail({
chatOffset?: number; chatOffset?: number;
}) { }) {
const [detail, setDetail] = useState<Detail | null>(null); const [detail, setDetail] = useState<Detail | null>(null);
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
const [pnl, setPnl] = useState<Pnl | null>(null); const [pnl, setPnl] = useState<Pnl | null>(null);
const [research, setResearch] = useState<Research | null>(null); const [research, setResearch] = useState<Research | null>(null);
const [tagText, setTagText] = useState(""); const [tagText, setTagText] = useState("");
@@ -77,6 +79,7 @@ export function AlphaDetail({
dirty.current = false; dirty.current = false;
setConflict(false); setConflict(false);
setDetail(null); setDetail(null);
setDescriptions({});
setResearch(null); setResearch(null);
setPnl(null); setPnl(null);
setError(""); setError("");
@@ -122,6 +125,21 @@ export function AlphaDetail({
active = false; active = false;
}; };
}, [id, tab, version]); }, [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() { async function fetchPnl() {
try { try {
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] }); await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
@@ -212,6 +230,11 @@ export function AlphaDetail({
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag> <Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
<Tag>{detail.language ?? "语言未提供"}</Tag> <Tag>{detail.language ?? "语言未提供"}</Tag>
<Tag>{detail.status ?? "状态未提供"}</Tag> <Tag>{detail.status ?? "状态未提供"}</Tag>
{detail.check_type === "PPAC_CANDIDATE" && (
<Tag color="yellow" size="small" type="light">
候选PPAC
</Tag>
)}
</div> </div>
</div> </div>
<a <a
@@ -222,6 +245,25 @@ export function AlphaDetail({
<Button>在 BRAIN 中打开 ↗</Button> <Button>在 BRAIN 中打开 ↗</Button>
</a> </a>
</div> </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 <Tabs
className="alpha-detail-tabs" className="alpha-detail-tabs"
activeKey={tab} activeKey={tab}
@@ -256,6 +298,18 @@ export function AlphaDetail({
{detail.combo !== null && ( {detail.combo !== null && (
<CodeBlock title="Combo" value={detail.combo} /> <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 /> <Divider />
<h3>回测设置</h3> <h3>回测设置</h3>
<DetailFieldGrid <DetailFieldGrid
@@ -266,6 +320,17 @@ export function AlphaDetail({
/> />
</div> </div>
</TabPane> </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"> <TabPane tab="指标" itemKey="metrics">
<MetricTable title="IS 指标" values={detail.is_metrics} /> <MetricTable title="IS 指标" values={detail.is_metrics} />
<Divider /> <Divider />
+13 -1
View File
@@ -34,10 +34,22 @@ const navigation = [
}, },
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" }, { id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
{ id: "variants", label: "Alpha 变体", 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: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" }, { id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" }, { id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, 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: "mcp-keys", label: "MCP Key", icon: IconCommand, group: "系统管理" },
{ id: "account", label: "个人信息", icon: IconUser, group: "" }, { id: "account", label: "个人信息", icon: IconUser, group: "" },
] as const; ] as const;
@@ -285,7 +297,7 @@ export function AppSidebar({
> >
<div className="command-items"> <div className="command-items">
{navigation.map(({ id, label, icon: Icon }) => ( {navigation.map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => navigate(id)}> <button key={id} aria-label={label} onClick={() => navigate(id)}>
<Icon /> <Icon />
{label} {label}
</button> </button>
+113 -25
View File
@@ -1,3 +1,4 @@
import { WorkspaceTable } from "../components/WorkspaceTable";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Banner, Banner,
@@ -10,7 +11,6 @@ import {
Popover, Popover,
Pagination, Pagination,
Select, Select,
Table,
Tag, Tag,
TextArea, TextArea,
Toast, Toast,
@@ -99,6 +99,7 @@ const checkLabels = {
PENDING: "待检查", PENDING: "待检查",
PRE_CHECK: "预检通过", PRE_CHECK: "预检通过",
PASS: "检查通过", PASS: "检查通过",
PPAC_CANDIDATE: "候选PPAC",
FAIL_1: "FAIL=1", FAIL_1: "FAIL=1",
FAIL_2: "FAIL≥2", FAIL_2: "FAIL≥2",
}; };
@@ -118,6 +119,9 @@ const initialColumns = [
"source", "source",
]; ];
const columnLabels: Record<string, string> = { const columnLabels: Record<string, string> = {
selection: "Selection",
combo: "Combo",
component_count: "组件数(已核实)",
source: "研究来源", source: "研究来源",
name: "Alpha", name: "Alpha",
expression: "表达式", expression: "表达式",
@@ -147,6 +151,7 @@ const columnLabels: Record<string, string> = {
}; };
export function AlphaPage({ export function AlphaPage({
managementScope = "non_super",
account, account,
taskPanelOpen, taskPanelOpen,
version, version,
@@ -160,6 +165,7 @@ export function AlphaPage({
onOverlay, onOverlay,
onAction, onAction,
}: { }: {
managementScope?: "super" | "non_super";
account: Account | null; account: Account | null;
taskPanelOpen: boolean; taskPanelOpen: boolean;
version: string; version: string;
@@ -173,6 +179,21 @@ export function AlphaPage({
onOverlay: () => void; onOverlay: () => void;
onAction: (action: WorkspaceAction) => 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>({ const [data, setData] = useState<Page>({
items: [], items: [],
total: 0, total: 0,
@@ -185,6 +206,7 @@ export function AlphaPage({
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [selectedViewId, setSelectedViewId] = useState<string | null>(null); const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
const [submissionBlocked, setSubmissionBlocked] = useState(false); const [submissionBlocked, setSubmissionBlocked] = useState(false);
const [ppacCandidate, setPpacCandidate] = useState(false);
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED"); const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
const [syncingScope, setSyncingScope] = useState<Submission | null>(null); const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -197,7 +219,7 @@ export function AlphaPage({
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => { const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
try { try {
const stored: unknown = JSON.parse( const stored: unknown = JSON.parse(
localStorage.getItem("alpha-columns") || "null", localStorage.getItem(columnKey) || "null",
); );
return Array.isArray(stored) return Array.isArray(stored)
? [ ? [
@@ -208,9 +230,9 @@ export function AlphaPage({
), ),
]), ]),
] ]
: initialColumns; : defaultColumns;
} catch { } catch {
return initialColumns; return defaultColumns;
} }
}); });
const [detailId, setDetailId] = useState<string | null>(null); const [detailId, setDetailId] = useState<string | null>(null);
@@ -223,6 +245,7 @@ export function AlphaPage({
version: number; version: number;
text: string; text: string;
} | null>(null); } | null>(null);
const selectionVersions = useRef<Record<string, number>>({});
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({}); const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
const actionNonce = useRef(0); const actionNonce = useRef(0);
const [bulk, setBulk] = useState({ const [bulk, setBulk] = useState({
@@ -250,22 +273,34 @@ export function AlphaPage({
const params = useMemo( const params = useMemo(
() => ({ () => ({
...filters, ...filters,
management_scope: managementScope,
submission, submission,
submission_blocked: submissionBlocked || undefined, submission_blocked: submissionBlocked || undefined,
ppac_candidate: ppacCandidate || undefined,
sort, sort,
direction, direction,
limit: pageSize, limit: pageSize,
offset: (page - 1) * 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); const query = queryString(params);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
onContext({ onContext({
page: "alphas", page: managementScope === "super" ? "superalphas" : "alphas",
alpha_id: detailId, alpha_id: detailId,
selected_ids: selected, selected_ids: selected.slice(0, 100),
filters: Object.fromEntries( filters: Object.fromEntries(
Object.entries(params).filter( Object.entries(params).filter(
([, value]) => value !== "" && value !== undefined && value !== null, ([, value]) => value !== "" && value !== undefined && value !== null,
@@ -274,7 +309,7 @@ export function AlphaPage({
}); });
}, [active, detailId, selected, params, onContext]); }, [active, detailId, selected, params, onContext]);
useEffect(() => { useEffect(() => {
if (!action || actionNonce.current === action.nonce) return; if (!active || !action || actionNonce.current === action.nonce) return;
actionNonce.current = action.nonce; actionNonce.current = action.nonce;
if (action.type === "open_alpha") { if (action.type === "open_alpha") {
setDetailId(action.alpha_id); setDetailId(action.alpha_id);
@@ -289,6 +324,7 @@ export function AlphaPage({
offset: _offset, offset: _offset,
submission: nextSubmission, submission: nextSubmission,
submission_blocked: nextBlocked, submission_blocked: nextBlocked,
ppac_candidate: nextPpacCandidate,
...values ...values
} = action.filters; } = action.filters;
const next = Object.fromEntries( const next = Object.fromEntries(
@@ -303,10 +339,13 @@ export function AlphaPage({
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED") if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
setSubmission(nextSubmission); setSubmission(nextSubmission);
setSubmissionBlocked(nextBlocked === true || nextBlocked === "true"); setSubmissionBlocked(nextBlocked === true || nextBlocked === "true");
setPpacCandidate(
nextPpacCandidate === true || nextPpacCandidate === "true",
);
if (nextSort) setSort(String(nextSort)); if (nextSort) setSort(String(nextSort));
if (nextDirection) setDirection(String(nextDirection)); if (nextDirection) setDirection(String(nextDirection));
} }
}, [action, onOverlay]); }, [action, onOverlay, active]);
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []); const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -315,7 +354,9 @@ export function AlphaPage({
setError(""); setError("");
Promise.all([ Promise.all([
api<Page>(`/alphas?${query}`, { signal: controller.signal }), 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]) => { .then(([results, nextFacets]) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -333,6 +374,9 @@ export function AlphaPage({
}, [active, query, version, localVersion]); }, [active, query, version, localVersion]);
useEffect(() => { useEffect(() => {
setSelected([]); setSelected([]);
selectionVersions.current = {};
}, [filters, submission, submissionBlocked, ppacCandidate, managementScope]);
useEffect(() => {
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 }); tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
}, [query]); }, [query]);
function updateDraft(key: string, value: unknown) { function updateDraft(key: string, value: unknown) {
@@ -358,6 +402,7 @@ export function AlphaPage({
setDirection("desc"); setDirection("desc");
setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED"); setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED");
setSubmissionBlocked(value === "SUBMISSION_BLOCKED"); setSubmissionBlocked(value === "SUBMISSION_BLOCKED");
setPpacCandidate(value === "PPAC_CANDIDATE");
setDraft({}); setDraft({});
setFilters({}); setFilters({});
setPage(1); setPage(1);
@@ -464,6 +509,24 @@ export function AlphaPage({
</code> </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", key: "source",
title: "研究来源", title: "研究来源",
@@ -528,8 +591,12 @@ export function AlphaPage({
width: 125, width: 125,
render: (_, row) => ( render: (_, row) => (
<Tag <Tag
size="small"
type="light"
color={ color={
row!.check_type?.startsWith("FAIL") row!.check_type === "PPAC_CANDIDATE"
? "yellow"
: row!.check_type?.startsWith("FAIL")
? "red" ? "red"
: row!.check_type === "PASS" : row!.check_type === "PASS"
? "green" ? "green"
@@ -953,8 +1020,10 @@ export function AlphaPage({
{error && <Banner type="danger" description={error} />} {error && <Banner type="danger" description={error} />}
<section className="library-panel" ref={tablePanel}> <section className="library-panel" ref={tablePanel}>
<SavedViews <SavedViews
managementScope={managementScope}
submission={submission} submission={submission}
submissionBlocked={submissionBlocked} submissionBlocked={submissionBlocked}
ppacCandidate={ppacCandidate}
selectedId={selectedViewId} selectedId={selectedViewId}
onSelect={setSelectedViewId} onSelect={setSelectedViewId}
onSubmissionChange={changeSubmission} onSubmissionChange={changeSubmission}
@@ -964,6 +1033,7 @@ export function AlphaPage({
...filters, ...filters,
submission, submission,
submission_blocked: submissionBlocked || undefined, submission_blocked: submissionBlocked || undefined,
ppac_candidate: ppacCandidate || undefined,
sort, sort,
direction, direction,
limit: pageSize, limit: pageSize,
@@ -974,6 +1044,7 @@ export function AlphaPage({
const { const {
submission: savedSubmission, submission: savedSubmission,
submission_blocked: savedBlocked, submission_blocked: savedBlocked,
ppac_candidate: savedPpacCandidate,
sort: savedSort, sort: savedSort,
direction: savedDirection, direction: savedDirection,
limit, limit,
@@ -995,6 +1066,9 @@ export function AlphaPage({
setSubmissionBlocked( setSubmissionBlocked(
savedBlocked === true || savedBlocked === "true", savedBlocked === true || savedBlocked === "true",
); );
setPpacCandidate(
savedPpacCandidate === true || savedPpacCandidate === "true",
);
setSort(String(savedSort || "date_created")); setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc")); setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit); if (typeof limit === "number") setPageSize(limit);
@@ -1005,7 +1079,7 @@ export function AlphaPage({
]), ]),
]; ];
setVisibleColumns(next); setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next)); localStorage.setItem(columnKey, JSON.stringify(next));
}} }}
/> />
<div <div
@@ -1058,7 +1132,7 @@ export function AlphaPage({
<CheckboxGroup <CheckboxGroup
direction="vertical" direction="vertical"
value={displayedColumns} value={displayedColumns}
options={Object.entries(columnLabels).map( options={Object.entries(menuColumns).map(
([value, label]) => ({ ([value, label]) => ({
value, value,
label, label,
@@ -1071,10 +1145,7 @@ export function AlphaPage({
onChange={(values) => { onChange={(values) => {
const next = values.map(String); const next = values.map(String);
setVisibleColumns(next); setVisibleColumns(next);
localStorage.setItem( localStorage.setItem(columnKey, JSON.stringify(next));
"alpha-columns",
JSON.stringify(next),
);
}} }}
/> />
</div> </div>
@@ -1221,9 +1292,7 @@ export function AlphaPage({
onClick={() => { onClick={() => {
setBulkVersions( setBulkVersions(
Object.fromEntries( Object.fromEntries(
data.items selected.map((id) => [id, selectionVersions.current[id]]),
.filter((row) => selected.includes(row.id))
.map((row) => [row.id, row.research.version]),
), ),
); );
setBulkOpen(true); setBulkOpen(true);
@@ -1252,7 +1321,8 @@ export function AlphaPage({
</Button> </Button>
</div> </div>
)} )}
<Table<Alpha> <WorkspaceTable<Alpha>
fill
columns={columns} columns={columns}
dataSource={data.items} dataSource={data.items}
rowKey="id" rowKey="id"
@@ -1265,7 +1335,19 @@ export function AlphaPage({
}} }}
rowSelection={{ rowSelection={{
selectedRowKeys: selected, 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, fixed: !compact,
}} }}
pagination={false} pagination={false}
@@ -1273,12 +1355,16 @@ export function AlphaPage({
<Empty <Empty
className="alpha-empty" className="alpha-empty"
title={ title={
activeFilterCount ppacCandidate
? "没有符合条件的候选PPAC"
: activeFilterCount
? "没有符合条件的 Alpha" ? "没有符合条件的 Alpha"
: "从第一个 Alpha 开始" : "从第一个 Alpha 开始"
} }
description={ description={
activeFilterCount ppacCandidate
? "仅收录未提交且唯一失败项为 PURE_POWER_POOL_THEME 的 Alpha,等待平台开放 PPAC 主题。"
: activeFilterCount
? "调整筛选条件,探索更多信号。" ? "调整筛选条件,探索更多信号。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。" : "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
} }
@@ -1288,7 +1374,9 @@ export function AlphaPage({
<footer className="table-pagination" aria-label="Alpha 分页"> <footer className="table-pagination" aria-label="Alpha 分页">
<div className="alpha-table-meta"> <div className="alpha-table-meta">
<span className="muted"> <span className="muted">
{submissionBlocked {ppacCandidate
? "候选PPAC"
: submissionBlocked
? "提交受阻" ? "提交受阻"
: submission === "UNSUBMITTED" : submission === "UNSUBMITTED"
? "待提交" ? "待提交"
+3 -3
View File
@@ -25,12 +25,12 @@ const scopes = [
[ [
"research:refresh", "research:refresh",
"刷新研究数据", "刷新研究数据",
"更新缓存、检查自相关及恢复 WorldQuant 认证", "更新缓存、预览 SUPER 组件、检查自相关及恢复 WorldQuant 认证",
], ],
[ [
"research:write", "research:write",
"保存研究模板", "保存研究模板与方案",
"保存大模型总结的模板及来源,供后续批量回测", "保存模板、Super Alpha 方案及构造候选,不执行回测",
], ],
["backtests:execute", "执行回测", "提交新的回测批次"], ["backtests:execute", "执行回测", "提交新的回测批次"],
["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"], ["backtests:control", "控制回测", "暂停、继续、停止与恢复采集"],
+93
View File
@@ -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>
</>
);
}
+11 -178
View File
@@ -1,19 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
Banner,
Button,
Checkbox,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api"; import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types"; import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes"; import type { Experiment } from "./workspaceTypes";
import { EvaluationPanel } from "./EvaluationPanel"; import { EvaluationPanel } from "./EvaluationPanel";
import { LineagePanel } from "./LineagePanel"; import { LineagePanel } from "./LineagePanel";
import { ResearchSelect } from "./ResearchSelect"; import { ResearchSelect } from "./ResearchSelect";
import { validationLabel } from "./workspaceTypes"; import { CandidateTable } from "./CandidateTable";
import { DeleteResearchButton } from "./DeleteResearchButton"; import { DeleteResearchButton } from "./DeleteResearchButton";
export function ExperimentView({ export function ExperimentView({
@@ -33,7 +26,7 @@ export function ExperimentView({
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]); const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const valid = experiment.candidates.filter( const valid = experiment.candidates.filter(
(c) => c.validation.status === "valid", (c) => c.validation?.status === "valid",
); );
async function preview() { async function preview() {
setBusy(true); setBusy(true);
@@ -72,7 +65,10 @@ export function ExperimentView({
生成回测确认预览{selected.length ? `(${selected.length})` : ""} 生成回测确认预览{selected.length ? `(${selected.length})` : ""}
</Button> </Button>
</div> </div>
<details>
<summary>研究描述</summary>
<p>{experiment.hypothesis}</p> <p>{experiment.hypothesis}</p>
</details>
{experiment.archived ? ( {experiment.archived ? (
<Banner <Banner
type="info" type="info"
@@ -92,175 +88,12 @@ export function ExperimentView({
type="info" type="info"
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。" description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
/> />
<div className="research-table-scroll"> <CandidateTable
<Table<Experiment["candidates"][number]> candidates={experiment.candidates}
className="research-table" selected={selected}
dataSource={experiment.candidates} onSelectionChange={setSelected}
rowKey="client_item_id" requireValidation
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>
<div className="inline-actions"> <div className="inline-actions">
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button> <Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button> <Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
+200 -114
View File
@@ -13,9 +13,12 @@ import {
Input, Input,
InputNumber, InputNumber,
Table, Table,
Tabs,
TextArea, TextArea,
Toast, Toast,
Typography,
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import { WorkspaceTable } from "../components/WorkspaceTable";
import { ResearchInput } from "../ai/ResearchInput"; import { ResearchInput } from "../ai/ResearchInput";
import { IconAIEditLevel3 } from "@douyinfe/semi-icons"; import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
import { api, post, formatTime } from "../api"; import { api, post, formatTime } from "../api";
@@ -30,17 +33,11 @@ import {
} from "./workspaceTypes"; } from "./workspaceTypes";
import { TemplateEditor } from "./TemplateEditor"; import { TemplateEditor } from "./TemplateEditor";
import { ExperimentView } from "./ExperimentView"; import { ExperimentView } from "./ExperimentView";
import { TemplateCandidateSet } from "./TemplateCandidateSet";
import { DeleteResearchButton } from "./DeleteResearchButton"; import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel"; import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css"; import "./workspace.css";
type ImportResult = {
templates: Template[];
digest: string;
conflicts: unknown[];
errors: unknown[];
differences?: unknown[];
};
export function ResearchWorkspace({ export function ResearchWorkspace({
page, page,
active, active,
@@ -54,6 +51,7 @@ export function ResearchWorkspace({
onAction: (action: UIAction) => void; onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void; onContext: (context: PageContext) => void;
}) { }) {
const [detailTab, setDetailTab] = useState("editor");
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState<"editor" | "experiment">( const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
"editor", "editor",
@@ -86,11 +84,27 @@ export function ResearchWorkspace({
const [assetTotal, setAssetTotal] = useState(0); const [assetTotal, setAssetTotal] = useState(0);
const [historyTotal, setHistoryTotal] = useState(0); const [historyTotal, setHistoryTotal] = useState(0);
const refreshSequence = useRef(0); const refreshSequence = useRef(0);
const [importText, setImportText] = useState(""); const CandidateView =
const [importResult, setImportResult] = useState<ImportResult | null>(null); experiment?.kind === "template" ? TemplateCandidateSet : ExperimentView;
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id)); const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
const dirty = const dirty =
!asset || JSON.stringify(template) !== JSON.stringify(asset.content); !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>) { async function task(label: string, action: () => Promise<void>) {
setBusy(label); setBusy(label);
setError(""); setError("");
@@ -104,13 +118,12 @@ export function ResearchWorkspace({
} }
async function refresh() { async function refresh() {
const sequence = ++refreshSequence.current; const sequence = ++refreshSequence.current;
const [nextAssets, nextInputs, nextHistory] = await Promise.all([ const [nextAssets, nextHistory] = await Promise.all([
api<{ items: Asset[]; total: number }>( api<{ items: Asset[]; total: number }>(
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`, `/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
), ),
Promise.resolve({ items: inputs }),
api<{ items: typeof history; total: number }>( 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; if (sequence !== refreshSequence.current) return;
@@ -123,7 +136,6 @@ export function ResearchWorkspace({
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)), Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
); );
setAssets(nextAssets.items); setAssets(nextAssets.items);
setInputs(nextInputs.items);
setHistory(nextHistory.items); setHistory(nextHistory.items);
} }
useEffect(() => { useEffect(() => {
@@ -150,6 +162,7 @@ export function ResearchWorkspace({
["open_experiment", "open_template", "open_variant"].includes(action.type) ["open_experiment", "open_template", "open_variant"].includes(action.type)
) { ) {
setDetailOpen(true); setDetailOpen(true);
setDetailTab("editor");
setDetailMode( setDetailMode(
action.type === "open_experiment" ? "experiment" : "editor", action.type === "open_experiment" ? "experiment" : "editor",
); );
@@ -165,32 +178,34 @@ export function ResearchWorkspace({
const next = await api<Asset>( const next = await api<Asset>(
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`, `/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
); );
setAsset(next); openTemplate(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,
}));
}
}); });
if (action.type === "open_variant") { if (action.type === "open_variant") {
setParent(action.alpha_id); setParent(action.alpha_id);
setMethod("structure"); setMethod("structure");
} }
}, [active, action]); }, [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() { async function save() {
const next = asset const next = asset
? await api<Asset>(`/research/assets/${asset.id}`, { ? await api<Asset>(`/research/assets/${asset.id}`, {
@@ -253,14 +268,21 @@ export function ResearchWorkspace({
asset_id: asset!.id, asset_id: asset!.id,
version: asset!.version, version: asset!.version,
...researchSelection(inputIds, inputs), ...researchSelection(inputIds, inputs),
hypothesis, hypothesis:
page === "templates"
? template.description.trim() || `使用模板:${template.name}`
: hypothesis,
settings, settings,
mode, mode,
limit, limit,
seed, seed,
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [], parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
}); });
if (page === "templates" && currentGeneration.current !== generationKey) {
throw new Error("回测准备已改变,请按当前配置重新生成候选集合");
}
setExperiment(next); setExperiment(next);
setDetailTab("prepare");
await refresh(); await refresh();
} }
const combination = Object.values(template.variables) const combination = Object.values(template.variables)
@@ -308,6 +330,11 @@ export function ResearchWorkspace({
setAsset(null); setAsset(null);
setTemplate(blankTemplate()); setTemplate(blankTemplate());
setExperiment(null); setExperiment(null);
setInputIds([]);
setInputs([]);
setHypothesis("");
setSettings(initialSettings);
setDetailTab("editor");
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
}} }}
@@ -354,20 +381,20 @@ export function ResearchWorkspace({
} }
> >
{listMode === "assets" ? ( {listMode === "assets" ? (
<> <div className="research-template-table-container">
<Table<Asset> <WorkspaceTable<Asset>
className="research-table" key={JSON.stringify([search, assetPage])}
fill
dataSource={assets} dataSource={assets}
rowKey="id" rowKey="id"
pagination={false} scroll={{ x: 800, y: "100%" }}
size="small" empty="还没有模板。新建一个模板开始研究。"
sticky
style={{ minWidth: 720 }}
empty="还没有模板。新建或导入一个模板开始研究。"
columns={[ columns={[
{ {
title: "模板名称", title: "模板名称",
key: "name", key: "name",
width: 280,
ellipsis: { showTitle: false },
render: (_, item) => ( render: (_, item) => (
<Button <Button
theme="borderless" theme="borderless"
@@ -376,15 +403,20 @@ export function ResearchWorkspace({
const next = await api<Asset>( const next = await api<Asset>(
`/research/assets/${item.id}`, `/research/assets/${item.id}`,
); );
setAsset(next); openTemplate(next);
setTemplate(next.content); setDetailTab("editor");
setExperiment(null);
setDetailMode("editor"); setDetailMode("editor");
setDetailOpen(true); setDetailOpen(true);
}) })
} }
>
<Typography.Text
size="inherit"
style={{ width: "100%", color: "inherit" }}
ellipsis={{ showTooltip: true }}
> >
{item.name} {item.name}
</Typography.Text>
</Button> </Button>
), ),
}, },
@@ -397,8 +429,17 @@ export function ResearchWorkspace({
{ {
title: "表达式", title: "表达式",
key: "expression", key: "expression",
width: 260, width: undefined,
render: (_, item) => <code>{item.content.expression}</code>, ellipsis: { showTitle: false },
render: (_, item) => (
<Typography.Text
className="research-template-expression"
style={{ width: "100%" }}
ellipsis={{ showTooltip: true }}
>
{item.content.expression}
</Typography.Text>
),
}, },
{ {
title: "分类", title: "分类",
@@ -412,6 +453,7 @@ export function ResearchWorkspace({
key: "actions", key: "actions",
width: 80, width: 80,
render: (_, item) => ( render: (_, item) => (
<div className="workspace-cell-actions">
<DeleteResearchButton <DeleteResearchButton
name={item.name} name={item.name}
label="模板" label="模板"
@@ -420,11 +462,12 @@ export function ResearchWorkspace({
task("删除模板", () => removeAsset(item)) task("删除模板", () => removeAsset(item))
} }
/> />
</div>
), ),
}, },
]} ]}
/> />
</> </div>
) : ( ) : (
<> <>
<Table<(typeof history)[number]> <Table<(typeof history)[number]>
@@ -504,7 +547,7 @@ export function ResearchWorkspace({
{error && <Banner type="danger" description={error} />} {error && <Banner type="danger" description={error} />}
{detailMode === "experiment" ? ( {detailMode === "experiment" ? (
experiment && ( experiment && (
<ExperimentView <CandidateView
key={experiment.id} key={experiment.id}
experiment={experiment} experiment={experiment}
onAction={onAction} onAction={onAction}
@@ -519,9 +562,23 @@ export function ResearchWorkspace({
{asset?.archived && ( {asset?.archived && (
<Banner <Banner
type="info" type="info"
description="此模板已删除,当前显示保留的历史版本;可另存新模板继续编辑。" description="此模板已删除,当前仅显示保留的历史版本。"
/> />
)} )}
{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"> <section className="research-card">
<h3>研究输入与假设</h3> <h3>研究输入与假设</h3>
{page === "variants" && ( {page === "variants" && (
@@ -639,11 +696,15 @@ export function ResearchWorkspace({
</div> </div>
)} )}
</section> </section>
</div>
)}
{page !== "variants" || method === "structure" ? ( {page !== "variants" || method === "structure" ? (
<>
<div hidden={page === "templates" && detailTab !== "editor"}>
<section className="research-card"> <section className="research-card">
<div className="research-section-heading"> <div className="research-section-heading">
<h3> <h3>
编辑与展开{asset && ` · v${asset.version}`} 模板信息{asset && ` · v${asset.version}`}
{dirty && " · 未保存"} {dirty && " · 未保存"}
</h3> </h3>
</div> </div>
@@ -651,7 +712,26 @@ export function ResearchWorkspace({
value={template} value={template}
onChange={setTemplate} onChange={setTemplate}
inputs={selectedInputs} 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 && ( {asset && (
<div className="research-toolbar"> <div className="research-toolbar">
<span>版本记录</span> <span>版本记录</span>
@@ -664,7 +744,10 @@ export function ResearchWorkspace({
assets.find((item) => item.id === asset.id) assets.find((item) => item.id === asset.id)
?.version || asset.version, ?.version || asset.version,
}, },
(_, i) => ({ value: i + 1, label: `v${i + 1}` }), (_, i) => ({
value: i + 1,
label: `v${i + 1}`,
}),
)} )}
onChange={(version) => onChange={(version) =>
void task("读取历史版本", async () => { void task("读取历史版本", async () => {
@@ -708,6 +791,47 @@ export function ResearchWorkspace({
)} )}
<span>组合规模:{combination}</span> <span>组合规模:{combination}</span>
</div> </div>
</>
)}
</section>
</div>
<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"> <div className="research-form-grid">
<label> <label>
展开方式 展开方式
@@ -741,6 +865,9 @@ export function ResearchWorkspace({
</label> </label>
</div> </div>
<SimulationSettingsEditor <SimulationSettingsEditor
validationMode={
page === "templates" ? "combination" : "platform"
}
value={settings} value={settings}
onChange={setSettings} onChange={setSettings}
{...options} {...options}
@@ -754,15 +881,31 @@ export function ResearchWorkspace({
disabled={ disabled={
dirty || dirty ||
!inputIds.length || !inputIds.length ||
!hypothesis || (page === "variants" && !hypothesis) ||
!!busy || !!busy ||
!settingsValid !settingsValid
} }
onClick={() => void task("生成候选", expand)} onClick={() => void task("生成候选", expand)}
> >
保存候选研究记录 生成候选集合
</Button> </Button>
{(!inputIds.length ||
(page === "variants" && !hypothesis) ||
!settingsValid) && (
<p className="research-hint">
待完成:
{[
!inputIds.length && "选择固定研究输入",
page === "variants" && !hypothesis && "填写研究假设",
!settingsValid && "检查模拟设置",
]
.filter(Boolean)
.join("、")}
</p>
)}
</section> </section>
</div>
</>
) : ( ) : (
<section className="research-card"> <section className="research-card">
<h3>目标范围检查</h3> <h3>目标范围检查</h3>
@@ -789,8 +932,9 @@ export function ResearchWorkspace({
</Button> </Button>
</section> </section>
)} )}
<div hidden={page === "templates" && detailTab !== "prepare"}>
{experiment && ( {experiment && (
<ExperimentView <CandidateView
key={experiment.id} key={experiment.id}
experiment={experiment} experiment={experiment}
onAction={onAction} onAction={onAction}
@@ -800,65 +944,7 @@ export function ResearchWorkspace({
} }
/> />
)} )}
{page === "templates" && ( </div>
<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],
}),
);
})
}
>
预览导入差异
</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>
)}
{page === "variants" && ( {page === "variants" && (
<ComparisonPanel key={parent} baseline={parent} /> <ComparisonPanel key={parent} baseline={parent} />
)} )}
+27 -10
View File
@@ -44,10 +44,12 @@ function filterSignature(filters: Record<string, unknown>) {
} }
export function SavedViews({ export function SavedViews({
managementScope = "non_super",
filters, filters,
columns, columns,
submission, submission,
submissionBlocked, submissionBlocked,
ppacCandidate,
selectedId, selectedId,
onSelect, onSelect,
onSubmissionChange, onSubmissionChange,
@@ -55,10 +57,12 @@ export function SavedViews({
suspended, suspended,
onOverlay, onOverlay,
}: { }: {
managementScope?: "super" | "non_super";
filters: Record<string, unknown>; filters: Record<string, unknown>;
columns: string[]; columns: string[];
submission: Submission; submission: Submission;
submissionBlocked: boolean; submissionBlocked: boolean;
ppacCandidate: boolean;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onSubmissionChange: (submission: string) => void; onSubmissionChange: (submission: string) => void;
@@ -73,6 +77,11 @@ export function SavedViews({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const current = items.find((item) => item.id === selectedId); const current = items.find((item) => item.id === selectedId);
const fixedTab = ppacCandidate
? "PPAC_CANDIDATE"
: submissionBlocked
? "SUBMISSION_BLOCKED"
: submission;
const modified = const modified =
current && current &&
(filterSignature(filters) !== filterSignature(current.content.filters) || (filterSignature(filters) !== filterSignature(current.content.filters) ||
@@ -90,13 +99,22 @@ export function SavedViews({
views.push(...result.items); views.push(...result.items);
if (views.length >= result.total || result.items.length === 0) break; 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) => { void load().catch((error: Error) => {
if (!controller.signal.aborted) Toast.error(error.message); if (!controller.signal.aborted) Toast.error(error.message);
}); });
return () => controller.abort(); return () => controller.abort();
}, [revision]); }, [revision, managementScope]);
function openEditor(mode: Editor["mode"], view?: View) { function openEditor(mode: Editor["mode"], view?: View) {
const content = view?.content ?? { name: "", filters, columns }; const content = view?.content ?? { name: "", filters, columns };
@@ -154,10 +172,7 @@ export function SavedViews({
setItems((previous) => setItems((previous) =>
previous.filter((item) => item.id !== deleting.id), previous.filter((item) => item.id !== deleting.id),
); );
if (selectedId === deleting.id) if (selectedId === deleting.id) onSubmissionChange(fixedTab);
onSubmissionChange(
submissionBlocked ? "SUBMISSION_BLOCKED" : submission,
);
setDeleting(null); setDeleting(null);
Toast.success("视图已删除"); Toast.success("视图已删除");
} catch (error) { } catch (error) {
@@ -174,10 +189,7 @@ export function SavedViews({
type="card" type="card"
collapsible="auto" collapsible="auto"
tabPaneMotion={false} tabPaneMotion={false}
activeKey={ activeKey={selectedId ?? fixedTab}
selectedId ??
(submissionBlocked ? "SUBMISSION_BLOCKED" : submission)
}
onChange={(key) => { onChange={(key) => {
const view = items.find((item) => item.id === key); const view = items.find((item) => item.id === key);
if (view) { if (view) {
@@ -201,6 +213,11 @@ export function SavedViews({
tab: "提交受阻", tab: "提交受阻",
icon: <IconList aria-hidden="true" />, icon: <IconList aria-hidden="true" />,
}, },
{
itemKey: "PPAC_CANDIDATE",
tab: "候选PPAC",
icon: <IconList aria-hidden="true" />,
},
...items.map((view) => ({ ...items.map((view) => ({
itemKey: view.id, itemKey: view.id,
icon: <IconList aria-hidden="true" />, icon: <IconList aria-hidden="true" />,
+19
View File
@@ -5,6 +5,7 @@ import "./style.css";
export const sourceLabel = (kind: string) => export const sourceLabel = (kind: string) =>
({ ({
superalpha: "Super Alpha 研究",
chatbox: "Chatbox 研究", chatbox: "Chatbox 研究",
manual: "手工研究", manual: "手工研究",
mcp: "MCP 研究", mcp: "MCP 研究",
@@ -33,6 +34,24 @@ export function SourceDetails({
<p>来源引用:{source.reference}</p> <p>来源引用:{source.reference}</p>
)} )}
<div className="inline-actions"> <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 && {source.research_id &&
["template", "variant", "feature", "pipeline", "quantflow"].includes( ["template", "variant", "feature", "pipeline", "quantflow"].includes(
source.kind, 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>
);
}
+59 -4
View File
@@ -14,10 +14,12 @@ export function TemplateEditor({
value, value,
onChange, onChange,
inputs, inputs,
definitionsOnly = false,
}: { }: {
value: Template; value: Template;
onChange: (value: Template) => void; onChange: (value: Template) => void;
inputs: InputSnapshot[]; inputs: InputSnapshot[];
definitionsOnly?: boolean;
}) { }) {
function expression(text: string) { function expression(text: string) {
const names = [ const names = [
@@ -33,7 +35,9 @@ export function TemplateEditor({
variables: Object.fromEntries( variables: Object.fromEntries(
names.map((name) => [ names.map((name) => [
name, name,
value.variables[name] || { Object.hasOwn(value.variables, name)
? value.variables[name]
: {
kind: "field", kind: "field",
field_type: "MATRIX", field_type: "MATRIX",
values: [], values: [],
@@ -95,7 +99,56 @@ export function TemplateEditor({
使用 {"{name}"}{" "} 使用 {"{name}"}{" "}
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。 声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
</p> </p>
{Object.entries(value.variables).map(([name, item]) => ( {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}> <div className="research-variable" key={name}>
<strong>{`{${name}}`}</strong> <strong>{`{${name}}`}</strong>
<ResearchSelect <ResearchSelect
@@ -145,7 +198,8 @@ export function TemplateEditor({
values: text values: text
.split("\n") .split("\n")
.map((v) => .map((v) =>
["integer", "number"].includes(item.kind) && v.trim() !== "" ["integer", "number"].includes(item.kind) &&
v.trim() !== ""
? Number(v) ? Number(v)
: v, : v,
), ),
@@ -174,7 +228,8 @@ export function TemplateEditor({
</Button> </Button>
)} )}
</div> </div>
))} ),
)}
</div> </div>
); );
} }
+41
View File
@@ -104,6 +104,18 @@
.research-table-scroll { .research-table-scroll {
overflow-x: auto; 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 { .research-table .semi-table-row-cell {
vertical-align: top; vertical-align: top;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -362,3 +374,32 @@
flex-shrink: 0; flex-shrink: 0;
padding: 8px 0 12px; 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;
}
+2 -2
View File
@@ -10,6 +10,7 @@ export type Variable = {
| "string" | "string"
| "fragment"; | "fragment";
values: (string | number)[]; values: (string | number)[];
description?: string;
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null; field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
}; };
export type Template = { export type Template = {
@@ -18,7 +19,6 @@ export type Template = {
expression: string; expression: string;
variables: Record<string, Variable>; variables: Record<string, Variable>;
category: "template" | "fragment"; category: "template" | "fragment";
scope?: InputSnapshot["scope"] | null;
}; };
export type Asset = { export type Asset = {
id: string; id: string;
@@ -55,7 +55,7 @@ export type InputSnapshot = {
created_at: string; created_at: string;
}; };
export type ResearchCandidate = Candidate & { export type ResearchCandidate = Candidate & {
validation: { validation?: {
status: string; status: string;
syntax: string[]; syntax: string[];
types: string[]; types: string[];
@@ -17,6 +17,7 @@ import {
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */ /** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
export function SimulationSettingsEditor({ export function SimulationSettingsEditor({
alphaType = "REGULAR",
value, value,
onChange, onChange,
rows, rows,
@@ -27,7 +28,10 @@ export function SimulationSettingsEditor({
fixedScopes, fixedScopes,
disabled = false, disabled = false,
onValidityChange, onValidityChange,
validationMode = "platform",
}: { }: {
validationMode?: "platform" | "combination";
alphaType?: "REGULAR" | "SUPER";
value: SimulationSettings; value: SimulationSettings;
onChange: (value: SimulationSettings) => void; onChange: (value: SimulationSettings) => void;
rows: SettingsRow[]; rows: SettingsRow[];
@@ -41,10 +45,16 @@ export function SimulationSettingsEditor({
}) { }) {
const scope = settingsScope(value); const scope = settingsScope(value);
const row = rows.find((r) => sameScope(r, scope)); 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 [scopeReady, setScopeReady] = useState(false);
const valid = const valid = combinationOnly
!loading && ? !errors.length
: !loading &&
!error && !error &&
!errors.length && !errors.length &&
(fixedScopes !== undefined || scopeReady); (fixedScopes !== undefined || scopeReady);
@@ -52,7 +62,19 @@ export function SimulationSettingsEditor({
onValidityChange?.(valid); onValidityChange?.(valid);
}, [valid, onValidityChange]); }, [valid, onValidityChange]);
function field(key: string) { 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 = const current =
value[key as keyof SimulationSettings] ?? value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined); (key === "maxPosition" ? "OFF" : undefined);
@@ -69,7 +91,7 @@ export function SimulationSettingsEditor({
value={typeof current === "boolean" ? String(current) : current} value={typeof current === "boolean" ? String(current) : current}
disabled={ disabled={
disabled || disabled ||
loading || (loading && !combinationOnly) ||
!options.choices.length || !options.choices.length ||
(options.choices.length === 1 && !message) (options.choices.length === 1 && !message)
} }
@@ -95,11 +117,13 @@ export function SimulationSettingsEditor({
? current ? current
: undefined : undefined
} }
disabled={disabled || loading} disabled={disabled || (loading && !combinationOnly)}
min={options.minimum} min={options.minimum}
max={options.maximum} max={options.maximum}
precision={key === "decay" ? 0 : undefined} precision={
step={key === "decay" ? 1 : 0.01} ["decay", "selectionLimit"].includes(key) ? 0 : undefined
}
step={["decay", "selectionLimit"].includes(key) ? 1 : 0.01}
validateStatus={message ? "error" : "default"} validateStatus={message ? "error" : "default"}
onChange={(next) => onChange={(next) =>
onChange({ onChange({
@@ -125,16 +149,30 @@ export function SimulationSettingsEditor({
选项更新时间:{formatTime(fetchedAt ?? null)} 选项更新时间:{formatTime(fetchedAt ?? null)}
</small> </small>
{error && ( {error && (
<Banner type="warning" description={`${error};请重试同步合法设置`} /> <Banner
type="warning"
description={
combinationOnly
? "参数选项暂不可用,可使用当前参数继续生成候选。"
: `${error};请重试同步合法设置`
}
/>
)} )}
{!loading && !rows.length && !error && ( {!loading && !rows.length && !error && (
<Banner type="info" description="尚无参数选项,请先同步合法设置。" /> <Banner
type="info"
description={
combinationOnly
? "可同步参数选项辅助选择,当前参数仍可用于生成候选。"
: "尚无参数选项,请先同步合法设置。"
}
/>
)} )}
<ScopePicker <ScopePicker
value={scope} value={scope}
options={asScopeOptions(rows)} options={asScopeOptions(rows)}
readOnly={fixedScopes !== undefined} readOnly={fixedScopes !== undefined}
disabled={disabled || loading} disabled={disabled || (loading && !combinationOnly)}
onValidityChange={setScopeReady} onValidityChange={setScopeReady}
onChange={(next) => onChange={(next) =>
onChange({ onChange({
@@ -161,6 +199,23 @@ export function SimulationSettingsEditor({
<div className="settings-grid"> <div className="settings-grid">
{["neutralization", "decay", "truncation"].map(field)} {["neutralization", "decay", "truncation"].map(field)}
</div> </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> <details>
<summary>更多参数</summary> <summary>更多参数</summary>
<div className="settings-grid"> <div className="settings-grid">
+20 -3
View File
@@ -86,6 +86,9 @@ export function asScopeOptions(rows: SettingsRow[]): ScopeOption[] {
return rows.map((row) => ({ ...row, universes: [row.universe] })); return rows.map((row) => ({ ...row, universes: [row.universe] }));
} }
export const parameterLabels: Record<string, string> = { export const parameterLabels: Record<string, string> = {
selectionHandling: "Selection Handling",
selectionLimit: "Selection Limit",
componentActivation: "Component Activation",
neutralization: "Neutralization", neutralization: "Neutralization",
decay: "Decay", decay: "Decay",
truncation: "Truncation", 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. // These are application contract limits, not a substitute for account-specific market choices.
const contractFields: Record<string, FieldOption> = { 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 }, decay: { minimum: 0, maximum: 10000 },
truncation: { minimum: 0, maximum: 1 }, truncation: { minimum: 0, maximum: 1 },
pasteurization: { choices: ["ON", "OFF"] }, 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( result.choices = result.choices.filter(
(v) => (v) =>
typeof v === "number" && typeof v === "number" &&
@@ -171,6 +180,13 @@ export function settingsErrors(
) )
errors.push("所选数据准备必须具有相同组合,并与回测组合一致"); errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
for (const key of Object.keys(parameterLabels)) { for (const key of Object.keys(parameterLabels)) {
if (
["selectionHandling", "selectionLimit", "componentActivation"].includes(
key,
) &&
value.selectionHandling === undefined
)
continue;
const current = const current =
value[key as keyof SimulationSettings] ?? value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined); (key === "maxPosition" ? "OFF" : undefined);
@@ -183,12 +199,13 @@ export function settingsErrors(
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`, `${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
); );
if ( if (
(key === "decay" || key === "truncation") && ["decay", "truncation", "selectionLimit"].includes(key) &&
(typeof current !== "number" || (typeof current !== "number" ||
!Number.isFinite(current) || !Number.isFinite(current) ||
current < options.minimum! || current < options.minimum! ||
current > options.maximum! || current > options.maximum! ||
(key === "decay" && !Number.isInteger(current))) (["decay", "selectionLimit"].includes(key) &&
!Number.isInteger(current)))
) )
errors.push( errors.push(
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`, `${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