Compare commits
14 Commits
e256d6fef1
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bfc787691 | |||
| a1b160e1b0 | |||
| 79b432c3d0 | |||
| a3cb6dbacf | |||
| 5a7f39726b | |||
| 99bc36439e | |||
| 69c19ed25f | |||
| 13a2168ca5 | |||
| 07dd767c52 | |||
| 34f1a4fa77 | |||
| ba60d8e5c4 | |||
| 20f6d0fc51 | |||
| db449f0915 | |||
| 8444e3055e |
@@ -1,13 +1,15 @@
|
||||
name: Deploy production
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [prod]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Reuse the runner that deploys zhixing-system to the same Docker host.
|
||||
runs-on: ubuntu-latest
|
||||
# Manual runs must also select prod before receiving production credentials.
|
||||
if: ${{ github.ref == 'refs/heads/prod' }}
|
||||
# Match the label of tencent-prod-runner on server B, not its runner name.
|
||||
runs-on: tencent-prod
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
@@ -22,7 +24,6 @@ jobs:
|
||||
WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }}
|
||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||
ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }}
|
||||
DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }}
|
||||
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
|
||||
MCP_ENABLED: ${{ vars.MCP_ENABLED }}
|
||||
run: bash scripts/deploy-production.sh
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Alpha 管理候选PPAC
|
||||
Type: task
|
||||
Status: ready-for-agent
|
||||
|
||||
用户要求新增固定候选清单,并将对应检查结果显示为黄色“候选PPAC”Tag。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 仅有一项 Alpha FAIL 且名称为 PURE_POWER_POOL_THEME 时,检查结果派生为 PPAC_CANDIDATE;REGULAR_SUBMISSION 沿用既有排除口径,WARNING/PENDING 不计失败。
|
||||
- 固定“候选PPAC”Tab 通过 ppac_candidate=true 在数据库分页前筛选未提交候选;叠加筛选、分页、导出、保存/恢复视图及 AI 页面上下文保持一致。
|
||||
- 列表和详情显示黄色候选状态,保留原始 FAIL 检查证据与本地研究记录。候选身份不表示当前可正式提交。
|
||||
- 迁移 0022 复用已有 check_type 索引,仅重分类匹配的历史缓存。同步和主动检查沿用 snapshot_columns 自动刷新分类。
|
||||
|
||||
## 验证
|
||||
|
||||
分类边界、组合筛选/分页/导出、同步与主动检查后的状态迁移、隔离历史迁移、后端静态检查、前端构建和浏览器固定 Tab/黄色 Tag/保存恢复/窄屏验证。
|
||||
|
||||
## Comments
|
||||
|
||||
已完成本地实现与验证:95 项后端回归通过,包含分类边界、真实业务执行器对模拟 /check 的状态刷新、筛选/分页/导出/保存视图、503 条历史缓存的升级/降级/再升级及迁移 schema check;Ruff、TypeScript 与生产构建通过。
|
||||
|
||||
原有 Alpha 管理、提交受阻和本地相关性筛选 3 项浏览器回归通过。另在隔离临时库使用 56 条合成 PPAC Alpha 验证:固定 Tab、列表/详情黄色 Tag、原始 FAIL 保留、跨页勾选、搜索后回第一页及清空选择、保存/恢复视图、空结果和重置保持候选范围。1920px 桌面与 720px 窄屏完成截图检查,行高为 40px,窄屏分页底部 887px 位于 900px 视口内,页面未横向溢出,表体可独立横向滚动。
|
||||
|
||||
截图位于忽略目录 output/playwright/ppac-candidate-{desktop,detail,narrow}.png。构建保留已有 lottie eval 和大包提示;浏览器仅有登录前 auth/me 的 401 与 favicon 404。未部署、未迁移实际业务数据库、未请求真实平台。
|
||||
@@ -0,0 +1,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。验证使用隔离数据库和模拟平台。
|
||||
+13
-4
@@ -31,16 +31,20 @@ def snapshot_columns(settings, metrics, checks, *, checked=False):
|
||||
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check
|
||||
with no failures is PASS. WARNING/PENDING do not count as failures, matching
|
||||
the legacy workflow. Empty, malformed or unknown results remain PENDING.
|
||||
No submission eligibility or activity eligibility is inferred here.
|
||||
A sole PURE_POWER_POOL_THEME failure is a PPAC candidate, not confirmation
|
||||
of current submission or activity eligibility. Raw failures stay available.
|
||||
"""
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
metrics = metrics if isinstance(metrics, dict) else {}
|
||||
blocked = submission_limits(checks)["status"] == "blocked"
|
||||
checks, _ = split_checks(checks)
|
||||
valid = [check for check in checks if isinstance(check, dict)]
|
||||
failures = len(failed_checks(checks))
|
||||
failed_names = failed_checks(checks)
|
||||
failures = len(failed_names)
|
||||
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
|
||||
if failures:
|
||||
if failed_names == ["PURE_POWER_POOL_THEME"]:
|
||||
check_type = "PPAC_CANDIDATE"
|
||||
elif failures:
|
||||
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
|
||||
elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid):
|
||||
check_type = "PENDING"
|
||||
@@ -75,7 +79,7 @@ def check_summary(checks, *, check_type):
|
||||
"check_type": check_type,
|
||||
"failed_checks": failed_checks(checks),
|
||||
"submission_limits": submission_limits(checks),
|
||||
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
|
||||
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项;PPAC_CANDIDATE 为唯一失败项是 PURE_POWER_POOL_THEME 的候选。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +203,11 @@ def list_statement(filters):
|
||||
query = query.where(Alpha.submission_blocked == filters.submission_blocked)
|
||||
if filters.submission_blocked:
|
||||
query = query.where(submission_condition("UNSUBMITTED"))
|
||||
if filters.ppac_candidate is not None:
|
||||
candidate = Alpha.check_type == "PPAC_CANDIDATE"
|
||||
query = query.where(candidate if filters.ppac_candidate else ~candidate)
|
||||
if filters.ppac_candidate:
|
||||
query = query.where(submission_condition("UNSUBMITTED"))
|
||||
if (filters.local_correlation_status is not None or filters.local_correlation_min is not None
|
||||
or filters.local_correlation_max is not None):
|
||||
# One cache row per Alpha keeps totals/export stable; stale overrides the displayed status.
|
||||
|
||||
@@ -96,7 +96,7 @@ async def wake_backtests(runner, result):
|
||||
runner.backtests.wake.set()
|
||||
|
||||
|
||||
INSTRUCTIONS = "回测先读取能力再准备固定候选预览,每次运行确认一次;后续候选新建预览。停止生成不取消回测。\n回测结果追问用 get_backtest/get_backtest_results。上下文或历史没有运行 ID 时,可用 list_backtests 按 source=chatbox 和会话 reference 找回;不得把启动返回当作结果。"
|
||||
INSTRUCTIONS = "模板集合使用 start_template_backtest 直接请求确认;其他回测先读取能力再准备固定候选预览。每次运行确认一次;后续候选新建预览。停止生成不取消回测。\n回测结果追问用 get_backtest/get_backtest_results。上下文或历史没有运行 ID 时,可用 list_backtests 按 source=chatbox 和会话 reference 找回;不得把启动返回当作结果。"
|
||||
|
||||
|
||||
CAPABILITIES = (
|
||||
|
||||
@@ -33,7 +33,13 @@ TOOLS = {
|
||||
"get_pyramid_distribution": (c.PyramidQuery, "pyramid_distribution", "research:read", "实时读取指定 region(如 USA、GLB)和 delay(0/1)的个人 Pyramid Alpha 分布;必传 current_date(YYYY-MM-DD),自动按自然年四季度取完整起止日(如2026-09-13对应2026-07-01至2026-09-30),传给平台 startDate/endDate,不使用默认周期。按用户约定 alphaCount>=3 为 lit(已点亮),1–2 为 in_progress,0 为 unlit;每项含 category、alpha_count、距3条的 remaining。复用平台认证,未连接时先调用 authenticate_worldquant;缺失数据不当作0。不回测、不提交。"),
|
||||
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
|
||||
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
|
||||
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
|
||||
"search_research_templates": (c.TemplateSearch, "templates", "research:read", "分页搜索模板工坊的模板与最新版本,不执行研究。"),
|
||||
"get_research_template": (c.TemplateRead, "template", "research:read", "读取模板内容、字段定义和来源;新增版本前核对最新版本。"),
|
||||
"create_research_template_version": (c.CreateTemplateVersion, "create_template_version", "research:write", "为已有模板新增不可变版本。先读取模板,携带 template_id、expected_version、完整 template、hypothesis 和 idempotency_key。source_item_ids 可选,提供时须为真实、已完成采集的回测项。版本冲突须重新读取;不调用模型、不回测。"),
|
||||
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "保存调用方编写的模板,不要求已有回测结果。提供 template、hypothesis、唯一名称和 idempotency_key;source_item_ids 可选,提供时须为真实、已完成采集的回测项。template 使用 {name} 占位符及对应 variables,字段定义只需类型和描述;空字段 values 由展开时的数据准备绑定,其他空参数须补充 values 或直接写入表达式。仅保存模板,不调用模型、不展开、不执行回测。"),
|
||||
"expand_research_template": (c.TemplateExpansion, "expand_template", "research:write", "按 template_id/version、preparation_refs 和完整 settings 生成固定候选集合,支持全组合或固定 seed 随机采样。仅检查语法和数据准备/回测参数组合一致性,无逐行校验状态,不回测。返回首25项和 experiment_id,更多候选用 get_template_candidates 分页读取;用户授权后按明确 candidate_ids 调用 start_template_backtest。重试复用 idempotency_key。"),
|
||||
"get_template_candidates": (c.TemplateCandidates, "template_candidates", "research:read", "分页读取固定模板候选集合的表达式、参数、候选 ID、模板版本、数据准备及关联回测;每页最多100条,total 不是当前页数量。"),
|
||||
"start_template_backtest": (c.SubmitTemplateBacktest, "start_template_backtest", "backtests:execute", "对用户已授权的模板候选集合执行批量回测。提供 experiment_id、明确的 candidate_ids 和 idempotency_key;服务端使用保存的表达式和参数并保留来源,不接受重写输入。不需要另建预览,不重新校验类型或平台可用性。相同请求重试返回同一运行;新幂等键会创建新的回测,包括重复候选。立即返回运行 ID,结果另行查询。"),
|
||||
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
|
||||
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
|
||||
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
|
||||
@@ -67,7 +73,7 @@ class MCPResearchServer:
|
||||
self.mutation_lock = asyncio.Lock()
|
||||
self.server = Server("wq-alpha-research", version="1.0.0", on_list_tools=self.list_tools,
|
||||
on_call_tool=self.call_tool,
|
||||
instructions="自由探索,直接固定候选回测,无需先建研究资产。工具不安排定时研究;结果按运行 ID 查询。")
|
||||
instructions="自由探索,直接固定候选回测,无需先建研究资产。使用已有模板时,expand_research_template 生成候选,get_template_candidates 分页核对,获得用户授权后 start_template_backtest 执行;无需额外预览或结果评估步骤。工具不安排定时研究;结果按运行 ID 查询。")
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
host = urlsplit(settings.public_origin).netloc
|
||||
@@ -82,8 +88,8 @@ class MCPResearchServer:
|
||||
return types.ListToolsResult(tools=[types.Tool(name=name, description=description,
|
||||
inputSchema=schema.model_json_schema(), annotations=types.ToolAnnotations(
|
||||
readOnlyHint=scope == "research:read", destructiveHint=method == "control",
|
||||
idempotentHint=method in {"submit", "control", "create_template", "save_super_plan", "build_super_candidates"} or scope == "research:read",
|
||||
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection"}))
|
||||
idempotentHint=method in {"submit", "control", "create_template", "create_template_version", "save_super_plan", "build_super_candidates", "expand_template", "start_template_backtest"} or scope == "research:read",
|
||||
openWorldHint=method in {"refresh", "submit", "metadata", "check_self_correlation", "check_submission", "authenticate", "pyramid_distribution", "preview_super_selection", "start_template_backtest"}))
|
||||
for name, (schema, method, scope, description) in TOOLS.items()
|
||||
if scope in principal.scopes and "research:read" in principal.scopes])
|
||||
|
||||
@@ -136,7 +142,7 @@ class MCPResearchServer:
|
||||
data = {"error": ResearchError(code, "研究操作失败;可使用原幂等键重试或查询历史", retryable=True).data}
|
||||
db.add(MCPAudit(id=str(uuid4()), token_id=principal.token_id, tool=name,
|
||||
request_id=fingerprint({"request_id": request_id}), input_digest=digest,
|
||||
business_id=data.get("backtest_run_id", data.get("job_id", data.get("template_id", data.get("id")))),
|
||||
business_id=data.get("backtest_run_id", data.get("job_id", data.get("experiment_id", data.get("template_id", data.get("id"))))),
|
||||
result_code=code, elapsed_ms=int((time.monotonic()-started)*1000)))
|
||||
if not error:
|
||||
if access.wake == "backtests":
|
||||
|
||||
@@ -7,14 +7,22 @@ from collections import defaultdict
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source
|
||||
from ..backtests.contracts import (
|
||||
Candidate,
|
||||
DraftInput,
|
||||
PreviewInput,
|
||||
SimulationSettings,
|
||||
Source,
|
||||
StartInput,
|
||||
fingerprint,
|
||||
)
|
||||
from ..backtests.service import Backtests, uid
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
from ..catalog.service import Catalog
|
||||
from ..models import Alpha, BacktestRun, CatalogResource, ResearchExperiment
|
||||
from ..models import Account, Alpha, BacktestPreview, BacktestRun, CatalogResource, ResearchExperiment
|
||||
from ..preparations.service import Preparations
|
||||
from .assets import Assets
|
||||
from .expressions import GROUPS, analyze, expand
|
||||
from .expressions import GROUPS, ExpressionError, Parser, analyze, expand
|
||||
from .serialization import encode_snapshot as jsonable_encoder
|
||||
from .workspace_contracts import TemplateSpec
|
||||
|
||||
@@ -45,7 +53,7 @@ class Experiments:
|
||||
self.catalog = Catalog(db)
|
||||
self.assets = Assets(db)
|
||||
|
||||
async def inputs(self, ids, scope=None):
|
||||
async def inputs(self, ids, scope=None, *, check_types=True):
|
||||
if len(set(ids)) != len(ids):
|
||||
raise HTTPException(422, "输入快照重复")
|
||||
snapshots = [await self.catalog.input(input_id) for input_id in ids]
|
||||
@@ -56,7 +64,7 @@ class Experiments:
|
||||
for name, kind in item["field_types"].items():
|
||||
if name not in item["field_ids"]:
|
||||
continue
|
||||
if name in fields and fields[name] != kind:
|
||||
if check_types and name in fields and fields[name] != kind:
|
||||
raise HTTPException(422, f"字段 {name} 在不同快照中类型不一致")
|
||||
fields[name] = kind
|
||||
return snapshots, fields
|
||||
@@ -138,9 +146,9 @@ class Experiments:
|
||||
asset = await self.assets.get(body.asset_id, body.version, "template") if body.asset_id else None
|
||||
template = TemplateSpec.model_validate(asset["content"]) if asset else body.template
|
||||
scope = scope_of(body.settings)
|
||||
if template.scope and template.scope.model_dump() != scope:
|
||||
raise HTTPException(422, "模板适用范围与候选设置不同")
|
||||
snapshots, fields = await self.inputs(body.input_ids, scope)
|
||||
snapshots, fields = await self.inputs(body.input_ids, scope, check_types=kind != "template")
|
||||
if kind == "template" and not snapshots:
|
||||
raise HTTPException(422, "请先选择数据准备")
|
||||
parents = (
|
||||
parent_snapshots
|
||||
if parent_snapshots is not None
|
||||
@@ -148,31 +156,53 @@ class Experiments:
|
||||
)
|
||||
variables = {}
|
||||
for name, variable in template.variables.items():
|
||||
values = variable.values
|
||||
# Empty field definitions bind only to the selected immutable input scope.
|
||||
# Existing explicit domains remain restrictions and are never silently widened.
|
||||
if variable.kind == "field":
|
||||
for value in variable.values:
|
||||
if fields.get(str(value)) != variable.field_type:
|
||||
if not values:
|
||||
values = sorted(field for field, kind in fields.items() if kind == variable.field_type)
|
||||
if not values:
|
||||
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
|
||||
for value in values:
|
||||
if kind != "template" and fields.get(str(value)) != variable.field_type:
|
||||
raise HTTPException(422, f"变量 {name} 的字段 {value} 不在固定输入中或类型不符")
|
||||
if variable.kind == "group" and any(
|
||||
if kind != "template" and variable.kind == "group" and any(
|
||||
str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values
|
||||
):
|
||||
raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实")
|
||||
if not values:
|
||||
raise HTTPException(422, f"变量 {name} 缺少候选取值,请通过模板接口补充,或将固定参数直接写入表达式")
|
||||
variables[name] = [
|
||||
json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in variable.values
|
||||
json.dumps(v, ensure_ascii=False) if variable.kind == "string" else v for v in values
|
||||
]
|
||||
try:
|
||||
expanded = expand(template.expression, variables, body.mode, body.limit, body.seed)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from None
|
||||
validation_evidence = {}
|
||||
if kind != "template":
|
||||
operators_snapshot = await ResearchMetadata(self.db).get("operators")
|
||||
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
|
||||
setting_errors, settings_snapshot = await self.settings_check(body.settings)
|
||||
availability = await self.field_evidence(scope, fields)
|
||||
validation_evidence = {
|
||||
"field_availability": availability,
|
||||
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
|
||||
"operators_snapshot": operators_snapshot,
|
||||
"settings_snapshot": settings_snapshot,
|
||||
}
|
||||
candidates = []
|
||||
for index, item in enumerate(expanded["items"]):
|
||||
findings = {}
|
||||
if kind == "template":
|
||||
self.check_syntax(item["expression"], f"候选 {index + 1}")
|
||||
else:
|
||||
validation = self.validate(item["expression"], fields, operators, scope, availability)
|
||||
validation["availability"].extend(setting_errors)
|
||||
if setting_errors and validation["status"] == "valid":
|
||||
validation["status"] = "needs_review"
|
||||
findings["validation"] = validation
|
||||
candidates.append(
|
||||
{
|
||||
**Candidate(
|
||||
@@ -180,7 +210,7 @@ class Experiments:
|
||||
).model_dump(mode="json"),
|
||||
"bindings": item["bindings"],
|
||||
"input_ids": list(body.input_ids),
|
||||
"validation": validation,
|
||||
**findings,
|
||||
"changes": [
|
||||
self.diff(parent.get("expression", ""), item["expression"])
|
||||
for parent in parents
|
||||
@@ -190,16 +220,21 @@ class Experiments:
|
||||
)
|
||||
evidence = {
|
||||
"template": asset or {"content": template.model_dump(mode="json")},
|
||||
"field_availability": availability,
|
||||
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
|
||||
"combination_count": expanded["combination_count"],
|
||||
"seed": expanded["seed"],
|
||||
"operators_snapshot": operators_snapshot,
|
||||
"settings_snapshot": settings_snapshot,
|
||||
**validation_evidence,
|
||||
**(extra_evidence or {}),
|
||||
}
|
||||
return await self.save(template.name, kind, body.hypothesis, snapshots, parents, candidates, evidence)
|
||||
|
||||
@staticmethod
|
||||
def check_syntax(expression, label="表达式"):
|
||||
"""Reject unsupported syntax before persistence; platform semantics are not inferred."""
|
||||
try:
|
||||
Parser(expression).parse()
|
||||
except (ExpressionError, RecursionError) as exc:
|
||||
raise HTTPException(422, f"{label}语法错误:{exc}") from None
|
||||
|
||||
@staticmethod
|
||||
def diff(before, after):
|
||||
return [
|
||||
@@ -284,6 +319,26 @@ class Experiments:
|
||||
}
|
||||
)
|
||||
|
||||
async def template_candidates(self, experiment_id, limit=25, offset=0):
|
||||
"""Read a bounded page of stored template candidates and their immutable references."""
|
||||
experiment = await self.get(experiment_id)
|
||||
if experiment["kind"] != "template":
|
||||
raise HTTPException(422, "此入口仅用于模板候选集合")
|
||||
candidates = experiment["candidates"]
|
||||
template = experiment["evidence"].get("template", {})
|
||||
return {
|
||||
"id": experiment_id, "experiment_id": experiment_id,
|
||||
"name": experiment["name"], "archived": experiment["archived"],
|
||||
"template": {k: template.get(k) for k in ("id", "version", "name")},
|
||||
"inputs": [{k: item.get(k) for k in ("id", "preparation_id", "preparation_version", "scope")}
|
||||
for item in experiment["inputs"]],
|
||||
"items": [{k: c[k] for k in ("client_item_id", "expression", "settings", "alpha_type", "bindings") if k in c}
|
||||
for c in candidates[offset:offset + limit]],
|
||||
"total": len(candidates), "limit": limit, "offset": offset,
|
||||
"has_more": offset + limit < len(candidates),
|
||||
"backtest_run_ids": experiment["backtest_run_ids"],
|
||||
}
|
||||
|
||||
async def archive(self, experiment_id):
|
||||
"""Hide an immutable experiment; backtests and lineage must still resolve it.
|
||||
|
||||
@@ -297,7 +352,8 @@ class Experiments:
|
||||
raise HTTPException(404, "研究实验不存在")
|
||||
return {"ok": True}
|
||||
|
||||
async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
|
||||
async def backtest_input(self, experiment_id, candidate_ids=None, source_kind=None, reference=None):
|
||||
"""Build the complete fixed selection and enforce its domain checks."""
|
||||
experiment = await self.get(experiment_id)
|
||||
candidates = experiment["candidates"]
|
||||
if candidate_ids is not None:
|
||||
@@ -307,14 +363,21 @@ class Experiments:
|
||||
candidates = [item for item in candidates if item["client_item_id"] in chosen]
|
||||
if len(candidates) != len(chosen):
|
||||
raise HTTPException(422, "选择包含未知候选")
|
||||
else:
|
||||
elif experiment["kind"] != "template":
|
||||
candidates = [item for item in candidates if item["validation"]["status"] == "valid"]
|
||||
if not candidates or any(item["validation"]["status"] != "valid" for item in candidates):
|
||||
if not candidates:
|
||||
raise HTTPException(422, "请至少选择一条候选")
|
||||
if experiment["kind"] != "template" and any(item["validation"]["status"] != "valid" for item in candidates):
|
||||
raise HTTPException(422, "候选存在语法、类型或可用性问题,请先解决;至少保留一条已核实候选")
|
||||
inputs = experiment["inputs"]
|
||||
return await Backtests(self.db).preview(
|
||||
PreviewInput(
|
||||
inline=DraftInput(
|
||||
if experiment["kind"] == "template":
|
||||
# Historical collections follow the same syntax/scope contract; old row findings are irrelevant.
|
||||
for candidate in candidates:
|
||||
self.check_syntax(candidate["expression"], candidate["client_item_id"])
|
||||
scope = scope_of(SimulationSettings.model_validate(candidate["settings"]))
|
||||
if not inputs or any(item["scope"] != scope for item in inputs):
|
||||
raise HTTPException(422, "数据准备与回测参数组合不一致,请重新生成候选集合")
|
||||
return DraftInput(
|
||||
name=experiment["name"],
|
||||
source=Source(
|
||||
kind=source_kind or experiment["kind"],
|
||||
@@ -334,9 +397,55 @@ class Experiments:
|
||||
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"):
|
||||
parents = (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from ..backtests.contracts import RunOutput
|
||||
from ..security import require_auth
|
||||
from .assets import Assets
|
||||
from .comparisons import compare
|
||||
@@ -22,6 +23,7 @@ from .workspace_contracts import (
|
||||
ImportCommit,
|
||||
ImportPreview,
|
||||
SettingVariants,
|
||||
TemplateBacktest,
|
||||
WorkflowSpec,
|
||||
)
|
||||
|
||||
@@ -149,6 +151,14 @@ async def preview(experiment_id: str, body: ExperimentPreview, request: Request)
|
||||
return await Experiments(db).preview(experiment_id, body.candidate_ids)
|
||||
|
||||
|
||||
@router.post("/experiments/{experiment_id}/backtest", status_code=202, response_model=RunOutput)
|
||||
async def template_backtest(experiment_id: str, body: TemplateBacktest, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
result = await Experiments(db).start_template_backtest(experiment_id, body)
|
||||
request.app.state.runner.backtests.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/variants/settings", status_code=201)
|
||||
async def settings_variants(body: SettingVariants, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
|
||||
@@ -522,7 +522,8 @@ class ResearchRuntime:
|
||||
"type": "candidates",
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [
|
||||
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
|
||||
c["client_item_id"] for c in experiment["candidates"]
|
||||
if experiment["kind"] == "template" or c["validation"]["status"] == "valid"
|
||||
],
|
||||
}
|
||||
ids = step.output["candidate_ids"]
|
||||
|
||||
@@ -16,7 +16,8 @@ AssetKind = Literal["template", "feature", "view", "workflow", "superalpha_plan"
|
||||
|
||||
class Variable(Contract):
|
||||
kind: Literal["field", "operator", "integer", "number", "group", "string", "fragment"]
|
||||
values: list[str | int | float] = Field(min_length=1, max_length=10000)
|
||||
values: list[str | int | float] = Field(default_factory=list, max_length=10000)
|
||||
description: str = Field(default="", max_length=3000)
|
||||
field_type: Literal["MATRIX", "VECTOR", "GROUP"] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -44,7 +45,6 @@ class TemplateSpec(Contract):
|
||||
description: str = Field(default="", max_length=10000)
|
||||
expression: str = Field(min_length=1, max_length=20000)
|
||||
variables: dict[str, Variable] = Field(default_factory=dict, max_length=100)
|
||||
scope: Scope | None = None
|
||||
category: Literal["template", "fragment"] = "template"
|
||||
|
||||
@field_validator("expression")
|
||||
@@ -144,6 +144,11 @@ class ExperimentPreview(Contract):
|
||||
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class TemplateBacktest(Contract):
|
||||
candidate_ids: list[str] = Field(min_length=1, max_length=10000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class EvaluationRules(Contract):
|
||||
version: Literal["research-v1"] = "research-v1"
|
||||
sharpe_min: float = Field(default=1.0, allow_inf_nan=False)
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"""Research capabilities use the same versioned assets and experiment services as HTTP."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import Field
|
||||
|
||||
from ..ai.capabilities import Capability
|
||||
from ..backtests.ai_tools import wake_backtests
|
||||
from ..catalog.research_metadata import ResearchMetadata
|
||||
from ..schemas import Contract
|
||||
from .assets import Assets
|
||||
from .evaluations import Evaluations
|
||||
from .experiments import Experiments
|
||||
from .features import Features
|
||||
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, FeatureSpec, SettingVariants
|
||||
from .workspace_contracts import (
|
||||
AssetWrite,
|
||||
EvaluateInput,
|
||||
Expansion,
|
||||
FeatureSpec,
|
||||
SettingVariants,
|
||||
TemplateBacktest,
|
||||
TemplateSpec,
|
||||
)
|
||||
|
||||
|
||||
class AssetQuery(Contract):
|
||||
@@ -33,6 +43,10 @@ class FeatureWrite(Contract):
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class TemplateVersionWrite(FixedAssetReference):
|
||||
content: TemplateSpec
|
||||
|
||||
|
||||
class ExperimentReference(Contract):
|
||||
experiment_id: str = Field(min_length=1, max_length=36)
|
||||
|
||||
@@ -41,6 +55,31 @@ class CandidatePreview(ExperimentReference):
|
||||
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class TemplateBacktestRequest(TemplateBacktest, ExperimentReference):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateCandidateQuery(ExperimentReference):
|
||||
limit: int = Field(default=25, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
async def confirm_template_backtest(ctx, args):
|
||||
service = Experiments(ctx.business.db)
|
||||
experiment = await service.get(args.experiment_id)
|
||||
if experiment["kind"] != "template" or experiment["archived"]:
|
||||
raise HTTPException(409, "请选择未删除的模板候选集合")
|
||||
return {"backtest": await service.preview(
|
||||
args.experiment_id, args.candidate_ids, backtests=ctx.business.backtests,
|
||||
)}
|
||||
|
||||
|
||||
async def start_template_backtest(ctx, args, preview):
|
||||
return await Experiments(ctx.business.db).start_template_backtest(
|
||||
args.experiment_id, args, backtests=ctx.business.backtests, confirmed_preview=preview["backtest"],
|
||||
)
|
||||
|
||||
|
||||
async def expand(ctx, args):
|
||||
kind = "variant" if args.parent_alpha_ids or args.parent_experiment_ids else "template"
|
||||
return await Experiments(ctx.business.db).create(
|
||||
@@ -48,7 +87,7 @@ async def expand(ctx, args):
|
||||
)
|
||||
|
||||
|
||||
INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。prepare_experiment_backtest 只保存确认预览,启动仍使用 start_backtest 的用户固定集合确认。来源字段不能授予自动执行权限。"
|
||||
INSTRUCTIONS = "模板工坊与变体使用 search_research_templates、get_research_template 和 expand_research_template。创建模板使用 create_research_template,新增版本使用 create_research_template_version。变量可仅定义类型和描述;空字段候选由选定数据准备按类型绑定,其他空参数需补充 values 或直接写入表达式,不能猜测。引用模板必须固定版本;输入应先读取核实。expand 保存实验不会执行回测。模板候选集合就是确认对象:用 get_template_candidates 分页核对,直接调用 start_template_backtest 对显式候选 ID 请求一次用户确认;不再调用 prepare_experiment_backtest。模板只负责生成与回测关联,不要求评估研究结果或查看变体关系。变体仍可用 prepare_experiment_backtest 后调用 start_backtest 确认。来源字段不能授予自动执行权限。"
|
||||
CAPABILITIES = (
|
||||
Capability(
|
||||
name="search_research_templates",
|
||||
@@ -68,6 +107,29 @@ CAPABILITIES = (
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "template"),
|
||||
),
|
||||
Capability(
|
||||
name="create_research_template",
|
||||
schema=TemplateSpec,
|
||||
description="保存调用方编写的模板及字段定义;不调用模型、不生成候选或执行回测。",
|
||||
label="创建研究模板",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).save(
|
||||
AssetWrite(kind="template", content=args.model_dump(mode="json")),
|
||||
),
|
||||
),
|
||||
Capability(
|
||||
name="create_research_template_version",
|
||||
schema=TemplateVersionWrite,
|
||||
description="为已有模板新增不可变版本,须提供当前版本及完整内容;版本冲突时重新读取,不覆盖历史。",
|
||||
label="新增模板版本",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=lambda ctx, args: Assets(ctx.business.db).save(
|
||||
AssetWrite(kind="template", content=args.content.model_dump(mode="json"), version=args.version),
|
||||
args.asset_id,
|
||||
),
|
||||
),
|
||||
Capability(
|
||||
name="search_research_operators",
|
||||
schema=AssetQuery,
|
||||
@@ -80,12 +142,33 @@ CAPABILITIES = (
|
||||
Capability(
|
||||
name="expand_research_template",
|
||||
schema=Expansion,
|
||||
description="从固定输入和模板版本或内联模板保存不可变候选实验。包含分层校验,随机采样有数量上限,不开始回测。",
|
||||
description="从固定输入和模板版本或内联模板保存不可变候选实验。模板仅检查语法和数据准备与回测参数组合一致性;变体保留原校验。随机采样有数量上限,不开始回测。",
|
||||
label="展开模板候选",
|
||||
renderer="research",
|
||||
effect="prepare",
|
||||
handler=expand,
|
||||
),
|
||||
Capability(
|
||||
name="get_template_candidates",
|
||||
schema=TemplateCandidateQuery,
|
||||
description="分页读取模板候选集合的表达式、参数、候选 ID 和回测关联,不返回逐行校验状态。",
|
||||
label="读取模板候选",
|
||||
renderer="research",
|
||||
effect="query",
|
||||
handler=lambda ctx, args: Experiments(ctx.business.db).template_candidates(**args.model_dump()),
|
||||
),
|
||||
Capability(
|
||||
name="start_template_backtest",
|
||||
schema=TemplateBacktestRequest,
|
||||
description="直接对已保存模板集合中的显式候选 ID 请求一次用户确认,确认后批量回测;无需准备额外预览。重试复用幂等键。",
|
||||
label="回测模板候选",
|
||||
renderer="backtest",
|
||||
effect="confirm",
|
||||
preview=confirm_template_backtest,
|
||||
execute=start_template_backtest,
|
||||
after_commit=wake_backtests,
|
||||
refresh=("backtests",),
|
||||
),
|
||||
Capability(
|
||||
name="prepare_setting_variants",
|
||||
schema=SettingVariants,
|
||||
@@ -107,7 +190,7 @@ CAPABILITIES = (
|
||||
Capability(
|
||||
name="prepare_experiment_backtest",
|
||||
schema=CandidatePreview,
|
||||
description="从实验内已校验的固定候选保存回测确认预览,不启动模拟。",
|
||||
description="为变体等研究实验保存回测预览,不启动模拟;模板直接使用 start_template_backtest。",
|
||||
label="准备研究回测",
|
||||
renderer="backtest",
|
||||
effect="prepare",
|
||||
|
||||
@@ -116,7 +116,7 @@ class Control(Contract):
|
||||
class CreateTemplate(Contract):
|
||||
template: TemplateSpec
|
||||
hypothesis: str = Field(min_length=1, max_length=10000)
|
||||
source_item_ids: list[RunId] = Field(min_length=1, max_length=20)
|
||||
source_item_ids: list[RunId] = Field(default_factory=list, max_length=20)
|
||||
reference: str | None = Field(default=None, max_length=200)
|
||||
idempotency_key: Identifier
|
||||
|
||||
@@ -133,6 +133,41 @@ class CreateTemplate(Contract):
|
||||
return self
|
||||
|
||||
|
||||
class CreateTemplateVersion(CreateTemplate):
|
||||
template_id: RunId
|
||||
expected_version: int = Field(ge=1)
|
||||
|
||||
|
||||
class TemplateRead(Contract):
|
||||
template_id: RunId
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class TemplateSearch(Page):
|
||||
q: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class TemplateExpansion(Contract):
|
||||
template_id: RunId
|
||||
version: int = Field(ge=1)
|
||||
preparation_refs: list[PreparationReference] = Field(min_length=1, max_length=20)
|
||||
settings: CompleteSettings
|
||||
mode: Literal["all", "random"] = "all"
|
||||
limit: int = Field(default=100, ge=1, le=10000)
|
||||
seed: int = 0
|
||||
idempotency_key: Identifier
|
||||
|
||||
|
||||
class TemplateCandidates(Page):
|
||||
experiment_id: RunId
|
||||
|
||||
|
||||
class SubmitTemplateBacktest(Contract):
|
||||
experiment_id: RunId
|
||||
candidate_ids: list[Identifier] = Field(min_length=1, max_length=10000)
|
||||
idempotency_key: Identifier
|
||||
|
||||
|
||||
class CatalogSearch(Contract):
|
||||
filters: CatalogFilters
|
||||
dataset_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
@@ -81,7 +81,13 @@ class ResearchAccess(SuperResearchAccess):
|
||||
"metadata_only": True, "actual_platform_allowance": None,
|
||||
"templates": {
|
||||
"create_with": "create_research_template", "required_scope": "research:write",
|
||||
"authored_by": "caller", "max_source_items": 20,
|
||||
"version_with": "create_research_template_version",
|
||||
"read_with": "get_research_template", "search_with": "search_research_templates",
|
||||
"authored_by": "caller", "source_items_required": False, "max_source_items": 20,
|
||||
"expand_with": "expand_research_template", "candidates_with": "get_template_candidates",
|
||||
"backtest_with": "start_template_backtest", "execution_scope": "backtests:execute",
|
||||
"max_candidates": 10000, "page_size_max": 100,
|
||||
"validation": "syntax_and_preparation_settings_combination",
|
||||
"source_items_with": "get_backtest_results", "starts_backtests": False,
|
||||
"web_url": f"{self.public_origin}/#templates",
|
||||
},
|
||||
@@ -285,6 +291,76 @@ class ResearchAccess(SuperResearchAccess):
|
||||
return await self.remember("create_research_template", args, digest, result,
|
||||
business_id=result["template_id"])
|
||||
|
||||
async def template(self, args):
|
||||
"""Read the exact saved template revision without executing research."""
|
||||
from ..research.assets import Assets
|
||||
|
||||
return await Assets(self.db).get(args.template_id, args.version, "template")
|
||||
|
||||
async def templates(self, args):
|
||||
"""Search the same template library used by the browser."""
|
||||
from ..research.assets import Assets
|
||||
|
||||
return await Assets(self.db).list("template", **args.model_dump())
|
||||
|
||||
async def create_template_version(self, args):
|
||||
"""Append an idempotent, optimistic revision with refreshed source evidence."""
|
||||
from .templates import create_template
|
||||
|
||||
operation = "create_research_template_version"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
result = await create_template(self.db, args, self.principal, asset_id=args.template_id)
|
||||
result["web_url"] = f"{self.public_origin}/#templates"
|
||||
return await self.remember(operation, args, digest, result, business_id=args.template_id)
|
||||
|
||||
async def expand_template(self, args):
|
||||
"""Idempotently freeze a version and preparations; never start execution."""
|
||||
from ..research.experiments import Experiments
|
||||
from ..research.workspace_contracts import Expansion
|
||||
|
||||
operation = "expand_research_template"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
service = Experiments(self.db)
|
||||
asset = await service.assets.get(args.template_id, args.version, "template")
|
||||
experiment = await service.create(Expansion(
|
||||
asset_id=args.template_id, version=args.version, preparation_refs=args.preparation_refs,
|
||||
settings=args.settings, mode=args.mode, limit=args.limit, seed=args.seed,
|
||||
hypothesis=asset["content"].get("description", "").strip() or f"使用模板:{asset['name']}",
|
||||
), extra_evidence={"method": "template", "mcp_token_id": self.principal.token_id,
|
||||
"admin_id": self.principal.admin_id})
|
||||
result = await service.template_candidates(experiment["id"])
|
||||
result.update({"read_with": "get_template_candidates", "backtest_with": "start_template_backtest",
|
||||
"starts_backtests": False})
|
||||
return await self.remember(operation, args, digest, result, business_id=experiment["id"])
|
||||
|
||||
async def template_candidates(self, args):
|
||||
from ..research.experiments import Experiments
|
||||
|
||||
return await Experiments(self.db).template_candidates(args.experiment_id, args.limit, args.offset)
|
||||
|
||||
async def start_template_backtest(self, args):
|
||||
"""Execute only stored candidate IDs; authorization comes from the caller's execute scope."""
|
||||
from ..research.experiments import Experiments
|
||||
from ..research.workspace_contracts import TemplateBacktest
|
||||
|
||||
operation = "start_template_backtest"
|
||||
previous, digest = await self.previous(operation, args)
|
||||
if previous:
|
||||
return previous.response
|
||||
provenance = {"mcp_token_id": self.principal.token_id, "admin_id": self.principal.admin_id}
|
||||
result = await Experiments(self.db).start_template_backtest(
|
||||
args.experiment_id,
|
||||
TemplateBacktest(candidate_ids=args.candidate_ids, idempotency_key="mcp-template-" + str(uuid4())),
|
||||
backtests=Backtests(self.db, provenance),
|
||||
)
|
||||
result = {**result, "input_digest": digest, "web_url": self.run_url(result["backtest_run_id"])}
|
||||
return await self.remember(operation, args, digest, result,
|
||||
business_id=result["backtest_run_id"], wake="backtests")
|
||||
|
||||
async def previous(self, operation, args):
|
||||
# PostgreSQL row lock is shared with HTTP start and catalog/job creation.
|
||||
account = await self.db.scalar(select(Account).where(Account.id == self.principal.account_id).with_for_update())
|
||||
|
||||
@@ -10,23 +10,27 @@ from ..research.workspace_contracts import AssetWrite
|
||||
from .queries import item_summary
|
||||
|
||||
|
||||
async def create_template(db, args, principal):
|
||||
"""Save a new asset inside the caller's account-locked, idempotent transaction.
|
||||
async def create_template(db, args, principal, *, asset_id=None):
|
||||
"""Save a new asset or revision inside the caller's account-locked, idempotent transaction.
|
||||
|
||||
Args contain the external model's TemplateSpec and local source item IDs.
|
||||
Return the versioned asset and theoretical combination count. Raise
|
||||
ResearchError for name conflicts or missing/incomplete research evidence.
|
||||
Stored evidence proves provenance, not profitability or platform eligibility;
|
||||
schema validation does not validate every expanded FASTEXPR combination.
|
||||
Optional asset_id selects a version update guarded by args.expected_version;
|
||||
a stale version raises HTTP 409 and cannot overwrite a historical revision.
|
||||
"""
|
||||
from .service import ResearchError
|
||||
|
||||
existing = await db.scalar(select(ResearchAsset).where(
|
||||
ResearchAsset.kind == "template", ResearchAsset.name == args.template.name,
|
||||
ResearchAsset.id != asset_id if asset_id else True,
|
||||
).order_by(ResearchAsset.id).limit(1))
|
||||
if existing:
|
||||
raise ResearchError("TEMPLATE_NAME_CONFLICT", "模板名称已存在,请使用新名称;此工具不覆盖已有模板",
|
||||
affected_items=[{"template_id": existing.id, "version": existing.version}])
|
||||
previous = await Assets(db).get(asset_id, expected_kind="template") if asset_id else None
|
||||
rows = (await db.execute(select(BacktestItem, BacktestResult).outerjoin(
|
||||
BacktestResult, BacktestResult.item_id == BacktestItem.id,
|
||||
).where(BacktestItem.id.in_(args.source_item_ids)))).all()
|
||||
@@ -48,13 +52,17 @@ async def create_template(db, args, principal):
|
||||
"admin_id": principal.admin_id,
|
||||
"source_items": [item_summary(*found[item_id]) for item_id in args.source_item_ids],
|
||||
}
|
||||
if previous:
|
||||
provenance["parent_template"] = {"id": asset_id, "version": args.expected_version}
|
||||
asset = await Assets(db).save(AssetWrite(
|
||||
kind="template", content=args.template.model_dump(mode="json"),
|
||||
), provenance=provenance)
|
||||
version=args.expected_version if asset_id else None,
|
||||
), asset_id=asset_id, provenance=provenance)
|
||||
return {
|
||||
**asset, "template_id": asset["id"],
|
||||
"combination_count": str(math.prod(len(v.values) for v in args.template.variables.values())),
|
||||
"validation": {"structure": "valid", "source_evidence": "recorded",
|
||||
"combination_count": (str(math.prod(len(v.values) for v in args.template.variables.values()))
|
||||
if all(v.values for v in args.template.variables.values()) else None),
|
||||
"validation": {"structure": "valid", "source_evidence": "recorded" if args.source_item_ids else "not_provided",
|
||||
"expanded_candidates": "not_validated", "platform_semantics": "unknown"},
|
||||
"next_step": "在模板工坊选择固定输入及模拟设置,展开并核验候选,再确认批量回测。",
|
||||
"next_step": "选择数据准备和回测参数,用 expand_research_template 生成候选集合;核对候选后按已获授权范围调用 start_template_backtest。也可在模板工坊完成。",
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
|
||||
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
|
||||
Submission = Literal["UNSUBMITTED", "SUBMITTED"]
|
||||
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"]
|
||||
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "PPAC_CANDIDATE", "FAIL_1", "FAIL_2"]
|
||||
SortField = Literal[
|
||||
"id",
|
||||
"name",
|
||||
@@ -74,6 +74,7 @@ class AlphaFilters(Contract):
|
||||
local_correlation_min: float | None = Field(default=None, ge=-1, le=1)
|
||||
local_correlation_max: float | None = Field(default=None, ge=-1, le=1)
|
||||
submission_blocked: bool | None = None
|
||||
ppac_candidate: bool | None = None
|
||||
submission: Submission | None = None
|
||||
source: str | None = Field(default=None, max_length=100)
|
||||
source_reference: str | None = Field(default=None, max_length=200)
|
||||
|
||||
@@ -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
|
||||
@@ -162,7 +162,9 @@ async def test_official_sdk_client_and_error_contract(mcp_app):
|
||||
async with ClientSession(streams[0], streams[1]) as client:
|
||||
await client.initialize()
|
||||
listed = await client.list_tools()
|
||||
assert len(listed.tools) == 29
|
||||
assert len(listed.tools) == 35
|
||||
assert {"expand_research_template", "get_template_candidates", "start_template_backtest"} <= {t.name for t in listed.tools}
|
||||
assert {"search_research_templates", "get_research_template", "create_research_template_version"} <= {t.name for t in listed.tools}
|
||||
assert any(tool.name == "get_pyramid_distribution" for tool in listed.tools)
|
||||
assert {"search_data_preparations", "get_data_preparation"} <= {t.name for t in listed.tools}
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
|
||||
@@ -100,7 +100,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
|
||||
assert "submit_backtests" not in listed
|
||||
assert not tool.annotations.read_only_hint and not tool.annotations.destructive_hint
|
||||
assert tool.annotations.idempotent_hint and not tool.annotations.open_world_hint
|
||||
assert {"template", "hypothesis", "source_item_ids", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert {"template", "hypothesis", "idempotency_key"} <= set(tool.input_schema["required"])
|
||||
assert tool.input_schema["additionalProperties"] is False
|
||||
caps = await client.call_tool("get_research_capabilities", {})
|
||||
assert caps.structured_content["templates"]["create_with"] == TOOL
|
||||
@@ -138,7 +138,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
|
||||
assert {c["expression"] for c in experiment["candidates"]} == {
|
||||
f"rank({field}) + {offset}" for field in ["TEST_FIN_001", "TEST_FIN_002"] for offset in [0, 1, 5]
|
||||
}
|
||||
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
assert experiment["evidence"]["template"]["provenance"] == stored["provenance"]
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
@@ -169,7 +169,7 @@ async def test_template_invalid_inputs_and_missing_sources_are_atomic(app, compl
|
||||
principal, _ = await credentials(app)
|
||||
valid = template_request(completed_source["id"])
|
||||
variants = [
|
||||
{"source_item_ids": []}, {"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 21},
|
||||
{"source_item_ids": [completed_source["id"]] * 2}, {"hypothesis": " "},
|
||||
{"force": True}, {"idempotency_key": ""},
|
||||
{"template": valid["template"] | {"expression": "rank({missing})"}},
|
||||
@@ -236,3 +236,163 @@ async def test_browser_can_issue_template_only_and_all_permissions(app, logged_i
|
||||
async with app.state.sessions() as db:
|
||||
principal = await authenticate(db, response.json()["token"])
|
||||
assert principal.scopes == scopes
|
||||
|
||||
|
||||
async def test_mcp_template_version_is_idempotent_and_preserves_history(app, completed_source):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request(completed_source["id"])
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
content = deepcopy(body["template"])
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "数据准备中的矩阵字段"}
|
||||
update = {**body, "template": content, "template_id": created["id"],
|
||||
"expected_version": 1, "idempotency_key": "version-2"}
|
||||
tool = "create_research_template_version"
|
||||
first = await invoke(app, principal, tool, update)
|
||||
replay = await invoke(app, principal, tool, update)
|
||||
assert first == replay and first["version"] == 2
|
||||
assert first["combination_count"] is None
|
||||
assert first["provenance"]["parent_template"] == {"id": created["id"], "version": 1}
|
||||
read = await invoke(app, principal, "get_research_template", {"template_id": created["id"], "version": 1})
|
||||
assert read["content"]["variables"]["field"]["values"] == ["TEST_FIN_001", "TEST_FIN_002"]
|
||||
listed = await invoke(app, principal, "search_research_templates", {"q": content["name"]})
|
||||
assert listed["items"][0]["version"] == 2
|
||||
stale = await app.state.mcp.invoke(principal, tool, update | {"idempotency_key": "stale-version"})
|
||||
assert stale.is_error and stale.structured_content["error"]["code"] == "CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchRevision)) == 2
|
||||
denied, _ = await credentials(app, {"research:read"})
|
||||
from fastapi import HTTPException
|
||||
with pytest.raises(HTTPException) as forbidden:
|
||||
await app.state.mcp.invoke(denied, tool, update)
|
||||
assert forbidden.value.status_code == 403
|
||||
|
||||
|
||||
async def test_template_without_result_sources_can_be_created_and_versioned(app):
|
||||
principal, _ = await credentials(app, {"research:read", "research:write"})
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
created = await invoke(app, principal, TOOL, body)
|
||||
assert created["provenance"]["source_items"] == []
|
||||
assert created["validation"]["source_evidence"] == "not_provided"
|
||||
revised = await invoke(app, principal, "create_research_template_version", {
|
||||
**body, "template_id": created["id"], "expected_version": 1, "idempotency_key": "no-source-v2",
|
||||
"source_item_ids": [],
|
||||
})
|
||||
assert revised["version"] == 2 and revised["provenance"]["source_items"] == []
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
|
||||
async def template_collection(app, principal, research_input, count=130):
|
||||
body = template_request("unused")
|
||||
body.pop("source_item_ids")
|
||||
body["template"]["variables"]["field"]["values"] = ["TEST_FIN_001"]
|
||||
body["template"]["variables"]["offset"]["values"] = list(range(count))
|
||||
saved = await invoke(app, principal, TOOL, body)
|
||||
args = {
|
||||
"template_id": saved["id"], "version": saved["version"],
|
||||
"preparation_refs": [{"id": research_input["preparation_id"], "version": research_input["preparation_version"]}],
|
||||
"settings": candidate()["settings"], "limit": count, "idempotency_key": "expand-collection",
|
||||
}
|
||||
return saved, args
|
||||
|
||||
|
||||
async def test_template_collection_mcp_paging_execution_and_replay(app, logged_in, research_input):
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models import BacktestPreview, CatalogResource, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input)
|
||||
async with app.state.sessions.begin() as db:
|
||||
await db.execute(delete(CatalogResource))
|
||||
app.state.runner.backtests.wake.clear()
|
||||
first, retry = await asyncio.gather(*[invoke(app, principal, "expand_research_template", args) for _ in range(2)])
|
||||
assert first == retry and first["total"] == 130 and len(first["items"]) == 25
|
||||
assert first["has_more"] and not first["starts_backtests"]
|
||||
assert first["template"]["id"] == saved["id"] and first["template"]["version"] == 1
|
||||
assert not app.state.runner.backtests.wake.is_set()
|
||||
collected = []
|
||||
for offset in (0, 100):
|
||||
result = await invoke(app, principal, "get_template_candidates", {
|
||||
"experiment_id": first["experiment_id"], "limit": 100, "offset": offset,
|
||||
})
|
||||
collected += result["items"]
|
||||
assert len(collected) == 130 and not result["has_more"]
|
||||
assert all("validation" not in item for item in collected)
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
args = {"experiment_id": first["experiment_id"], "candidate_ids": [c["client_item_id"] for c in collected],
|
||||
"idempotency_key": "execute-collection"}
|
||||
run, replay = await asyncio.gather(*[invoke(app, principal, "start_template_backtest", args) for _ in range(2)])
|
||||
assert run == replay and run["total"] == 130
|
||||
assert run["source"]["kind"] == "template" and run["source"]["research_id"] == first["experiment_id"]
|
||||
assert run["source"]["input_snapshot_ids"]
|
||||
assert app.state.runner.backtests.wake.is_set()
|
||||
rotated, _ = await credentials(app)
|
||||
assert await invoke(app, rotated, "start_template_backtest", args) == run
|
||||
conflict = await app.state.mcp.invoke(principal, "start_template_backtest", args | {"candidate_ids": ["c1"]})
|
||||
assert conflict.is_error and conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
row = await db.get(BacktestRun, run["backtest_run_id"])
|
||||
assert row.ai_context["mcp_token_id"] == principal.token_id
|
||||
audit = await db.scalar(select(MCPAudit).where(MCPAudit.tool == "expand_research_template"))
|
||||
assert audit.business_id == first["experiment_id"]
|
||||
record = (await logged_in.get(f"/api/v1/research/experiments/{first['experiment_id']}")).json()
|
||||
assert record["backtest_run_ids"] == [run["backtest_run_id"]]
|
||||
|
||||
|
||||
async def test_template_mcp_failures_are_atomic_and_do_not_consume_keys(app, research_input):
|
||||
from app.models import BacktestPreview, ResearchExperiment
|
||||
|
||||
principal, _ = await credentials(app)
|
||||
saved, args = await template_collection(app, principal, research_input, 2)
|
||||
for changed in [args | {"settings": args["settings"] | {"region": "EUR"}},
|
||||
args | {"preparation_refs": [{**args["preparation_refs"][0], "version": 999}]}]:
|
||||
result = await app.state.mcp.invoke(principal, "expand_research_template", changed)
|
||||
assert result.is_error
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "expand_research_template"))
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
conflict = await app.state.mcp.invoke(principal, "expand_research_template", args | {"seed": 1})
|
||||
assert conflict.structured_content["error"]["code"] == "IDEMPOTENCY_CONFLICT"
|
||||
request = {"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "execute"}
|
||||
for ids in [[], ["unknown"], ["c1", "c1"]]:
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"candidate_ids": ids})
|
||||
assert result.is_error
|
||||
result = await app.state.mcp.invoke(principal, "start_template_backtest", request | {"expression": "rank(other)"})
|
||||
assert result.is_error and result.structured_content["error"]["code"] == "INVALID_INPUT"
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert not await db.scalar(select(ResearchRequest).where(ResearchRequest.operation == "start_template_backtest"))
|
||||
await invoke(app, principal, "start_template_backtest", request)
|
||||
|
||||
|
||||
async def test_template_tool_discovery_and_execute_permissions(app, research_input):
|
||||
from fastapi import HTTPException
|
||||
|
||||
principal, secret = await credentials(app, {"research:read", "research:write"})
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver",
|
||||
headers={"Authorization": f"Bearer {secret}", "Accept": "application/json, text/event-stream"}) as http:
|
||||
listed = (await http.post(ENDPOINT, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})).json()
|
||||
tools = {t["name"]: t for t in listed["result"]["tools"]}
|
||||
assert "expand_research_template" in tools and "get_template_candidates" in tools
|
||||
assert "start_template_backtest" not in tools
|
||||
assert "source_item_ids" not in tools[TOOL]["inputSchema"]["required"]
|
||||
assert tools["expand_research_template"]["annotations"]["idempotentHint"]
|
||||
_, args = await template_collection(app, principal, research_input, 2)
|
||||
collection = await invoke(app, principal, "expand_research_template", args)
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
await app.state.mcp.invoke(principal, "start_template_backtest", {
|
||||
"experiment_id": collection["experiment_id"], "candidate_ids": ["c1"], "idempotency_key": "denied",
|
||||
})
|
||||
assert denied.value.status_code == 403
|
||||
reader, _ = await credentials(app, {"research:read"})
|
||||
await invoke(app, reader, "get_template_candidates", {"experiment_id": collection["experiment_id"]})
|
||||
with pytest.raises(HTTPException):
|
||||
await app.state.mcp.invoke(reader, "expand_research_template", args)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""PPAC candidates retain their theme failure and wait for platform eligibility."""
|
||||
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from app.alphas import check_summary, snapshot_columns, upsert_alpha
|
||||
from tests import test_submission
|
||||
from tests.conftest import alpha
|
||||
|
||||
THEME = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
|
||||
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
|
||||
OTHER = {"name": "LOW_SHARPE", "result": "FAIL"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checked", [False, True])
|
||||
@pytest.mark.parametrize("checks,expected", [
|
||||
([THEME], "PPAC_CANDIDATE"),
|
||||
([{**THEME, "result": "fail"}], "PPAC_CANDIDATE"),
|
||||
([THEME, LIMIT, {"name": "MATCHES_THEMES", "result": "WARNING"},
|
||||
{"name": "PROD_CORRELATION", "result": "PENDING"}], "PPAC_CANDIDATE"),
|
||||
([THEME, OTHER], "FAIL_2"),
|
||||
([THEME, THEME], "FAIL_2"),
|
||||
([THEME, {"result": "FAIL"}], "FAIL_2"),
|
||||
([OTHER], "FAIL_1"),
|
||||
([{**THEME, "result": "PASS"}, OTHER], "FAIL_1"),
|
||||
([{**THEME, "result": False}], "PENDING"),
|
||||
([{**THEME, "value": False}], "PPAC_CANDIDATE"),
|
||||
([], "PENDING"),
|
||||
([{}], "PENDING"),
|
||||
])
|
||||
def test_only_a_single_explicit_theme_failure_is_a_candidate(checks, expected, checked):
|
||||
original = copy.deepcopy(checks)
|
||||
columns = snapshot_columns({}, {}, checks, checked=checked)
|
||||
assert columns["check_type"] == expected
|
||||
assert checks == original
|
||||
if expected == "PPAC_CANDIDATE":
|
||||
assert check_summary(checks, check_type=expected)["failed_checks"] == ["PURE_POWER_POOL_THEME"]
|
||||
|
||||
|
||||
async def test_ppac_scope_filters_before_pagination_and_export(app, logged_in):
|
||||
async with app.state.sessions.begin() as db:
|
||||
for name, checks, status, region, hidden in [
|
||||
("candidate1", [THEME], "UNSUBMITTED", "USA", False),
|
||||
("candidate2", [THEME, LIMIT], "UNSUBMITTED", "USA", True),
|
||||
("other_region", [THEME], "UNSUBMITTED", "CHN", False),
|
||||
("submitted", [THEME], "ACTIVE", "USA", False),
|
||||
("missing_status", [THEME], None, "USA", False),
|
||||
("two_failures", [THEME, OTHER], "UNSUBMITTED", "USA", False),
|
||||
("other_failure", [OTHER], "UNSUBMITTED", "USA", False),
|
||||
("passed", [{**THEME, "result": "PASS"}], "UNSUBMITTED", "USA", False),
|
||||
("missing", [], "UNSUBMITTED", "USA", False),
|
||||
]:
|
||||
await upsert_alpha(db, alpha(name, status=status, hidden=hidden, settings={"region": region},
|
||||
**{"is": {"checks": checks}}))
|
||||
query = "ppac_candidate=true®ion=USA&sort=id&direction=asc&limit=1&offset=1"
|
||||
response = await logged_in.get(f"/api/v1/alphas?{query}")
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()
|
||||
assert result["total"] == 2
|
||||
assert [row["id"] for row in result["items"]] == ["candidate2"]
|
||||
assert result["items"][0]["check_type"] == "PPAC_CANDIDATE"
|
||||
assert result["items"][0]["failed_checks"] == ["PURE_POWER_POOL_THEME"]
|
||||
export = await logged_in.get(f"/api/v1/alphas/export?{query}")
|
||||
rows = list(csv.DictReader(io.StringIO(export.text.lstrip("\ufeff"))))
|
||||
assert [row["id"] for row in rows] == ["candidate1", "candidate2"]
|
||||
assert {row["check_type"] for row in rows} == {"PPAC_CANDIDATE"}
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission=SUBMITTED")).json()["total"] == 0
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&check_type=FAIL_1")).json()["total"] == 0
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission_blocked=true")).json()["total"] == 1
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&q=candidate1")).json()["total"] == 1
|
||||
assert (await logged_in.get("/api/v1/alphas?check_type=PPAC_CANDIDATE&submission=UNSUBMITTED")).json()["total"] == 3
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=false")).json()["total"] == 4
|
||||
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={
|
||||
"kind": "view", "content": {"name": "候选PPAC", "filters": {
|
||||
"submission": "UNSUBMITTED", "ppac_candidate": True, "region": "USA",
|
||||
}, "columns": ["name", "check_type"]},
|
||||
})
|
||||
assert saved.status_code in (200, 201), saved.text
|
||||
assert saved.json()["content"]["filters"]["ppac_candidate"] is True
|
||||
|
||||
|
||||
async def test_check_and_sync_refresh_candidate_status_without_losing_evidence(app, logged_in, monkeypatch):
|
||||
await test_submission.setup(app, alpha(**{"is": {"checks": [OTHER]}}))
|
||||
for checks, expected in [([THEME, LIMIT], "PPAC_CANDIDATE"), ([{**THEME, "result": "PASS"}], "PASS")]:
|
||||
monkeypatch.setattr(test_submission, "CHECKS", checks)
|
||||
response = await test_submission.enqueue(logged_in)
|
||||
assert response.status_code == 202, response.text
|
||||
await app.state.runner.execute(response.json()["id"])
|
||||
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
|
||||
assert state["job"]["status"] == "completed"
|
||||
assert state["check_summary"]["check_type"] == expected
|
||||
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
|
||||
assert detail["checks"] == checks
|
||||
assert detail["check_type"] == expected
|
||||
assert detail["research"]["note"] == "preserve local research"
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
|
||||
for checks, expected in [([THEME], "PPAC_CANDIDATE"), ([THEME, OTHER], "FAIL_2")]:
|
||||
async with app.state.sessions.begin() as db:
|
||||
await upsert_alpha(db, alpha(**{"is": {"checks": checks}}))
|
||||
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
|
||||
assert detail["check_type"] == expected
|
||||
assert detail["checks"] == checks
|
||||
assert detail["research"]["note"] == "preserve local research"
|
||||
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Historical PPAC classification changes only matching cached check types."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
def test_ppac_backfill_preserves_evidence_across_batches_and_downgrade(tmp_path, monkeypatch):
|
||||
database = tmp_path / "ppac.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0021")
|
||||
engine = sa.create_engine(f"sqlite:///{database}")
|
||||
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
|
||||
theme = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
|
||||
other = {"name": "LOW_SHARPE", "result": "FAIL"}
|
||||
patterns = [
|
||||
([theme], "FAIL_1", "PPAC_CANDIDATE"),
|
||||
([{**theme, "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1", "PPAC_CANDIDATE"),
|
||||
([theme, other], "FAIL_2", "FAIL_2"),
|
||||
([theme, theme], "FAIL_2", "FAIL_2"),
|
||||
([other], "FAIL_1", "FAIL_1"),
|
||||
([{**theme, "result": "PASS"}], "PASS", "PASS"),
|
||||
([{**theme, "result": "WARNING"}], "PRE_CHECK", "PRE_CHECK"),
|
||||
([{}], "PENDING", "PENDING"),
|
||||
(None, "PENDING", "PENDING"),
|
||||
]
|
||||
with engine.begin() as db:
|
||||
db.execute(alphas.insert(), [
|
||||
{"id": f"ppac{i:04}", "status": "UNSUBMITTED", "hidden": False, "settings": {}, "os_metrics": {},
|
||||
"is_metrics": {"checks": patterns[i % len(patterns)][0]}, "checks": patterns[i % len(patterns)][0],
|
||||
"check_type": patterns[i % len(patterns)][1], "synced_at": datetime(2026, 9, 13, tzinfo=timezone.utc),
|
||||
"raw": {"is": {"checks": patterns[i % len(patterns)][0]}}}
|
||||
for i in range(503)
|
||||
])
|
||||
original = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
for target, position in [("0022", 2), ("0021", 1), ("0022", 2)]:
|
||||
(command.upgrade if target == "0022" else command.downgrade)(config, target)
|
||||
with engine.connect() as db:
|
||||
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
|
||||
assert len(rows) == 503
|
||||
for i, row in enumerate(rows):
|
||||
assert dict(row) == {**original[i], "check_type": patterns[i % len(patterns)][position]}
|
||||
command.upgrade(config, "head")
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
@@ -274,3 +274,58 @@ async def test_new_interfaces_require_login_and_same_origin(client):
|
||||
json=construction("none"),
|
||||
)
|
||||
).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["approve", "deny", "tamper", "archive"])
|
||||
async def test_template_collection_single_confirmation(app, logged_in, fixed_input, decision):
|
||||
from app.models import ResearchExperiment
|
||||
from tests.test_research_workspace import expansion
|
||||
|
||||
await setup(app)
|
||||
await configure(app, logged_in)
|
||||
body = expansion(fixed_input["id"])
|
||||
body["template"]["expression"] = "rank({field}) + {offset}"
|
||||
body["template"]["variables"]["offset"] = {"kind": "integer", "values": list(range(20))}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 201, response.text
|
||||
experiment = response.json()
|
||||
assert len(experiment["candidates"]) == 40
|
||||
app.state.ai.model_factory = single_tool_factory("start_template_backtest", {
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [c["client_item_id"] for c in experiment["candidates"]],
|
||||
"idempotency_key": "template-confirm",
|
||||
})
|
||||
conversation = (await logged_in.post("/api/v1/ai/conversations")).json()["id"]
|
||||
run = await ask(logged_in, conversation, "回测这个模板集合")
|
||||
assert run["status"] == "waiting_approval", run
|
||||
assert len(run["tools"]) == 1
|
||||
approval = run["tools"][0]
|
||||
preview = approval["preview"]["backtest"]
|
||||
assert preview["total"] == 40 and len(preview["items"]) < 40
|
||||
async with app.state.sessions.begin() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
if decision == "tamper":
|
||||
saved = await db.get(BacktestPreview, preview["preview_id"])
|
||||
candidates = copy.deepcopy(saved.candidates)
|
||||
candidates[-1]["expression"] = "rank(close)"
|
||||
saved.candidates = candidates
|
||||
elif decision == "archive":
|
||||
saved = await db.get(ResearchExperiment, experiment["id"])
|
||||
saved.archived = True
|
||||
for _ in range(2):
|
||||
result = await logged_in.post(f"/api/v1/ai/approvals/{approval['id']}/decision",
|
||||
json={"approved": decision != "deny"})
|
||||
assert result.status_code == 200, result.text
|
||||
async with app.state.sessions() as db:
|
||||
runs = (await db.scalars(select(BacktestRun))).all()
|
||||
assert len(runs) == (1 if decision == "approve" else 0)
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
if runs:
|
||||
assert runs[0].preview_id == preview["preview_id"]
|
||||
assert runs[0].source["kind"] == "template"
|
||||
assert runs[0].source["research_id"] == experiment["id"]
|
||||
assert runs[0].ai_context["conversation_id"] == conversation
|
||||
assert runs[0].ai_context["ai_run_id"] == run["id"]
|
||||
saved = await db.get(BacktestPreview, runs[0].preview_id)
|
||||
assert len(saved.candidates) == 40
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.catalog.research_metadata import ResearchMetadata
|
||||
from app.models import BacktestRun, CatalogResource, ResearchExperiment
|
||||
from app.research.expressions import analyze, expand
|
||||
from tests.conftest import alpha
|
||||
from tests.test_backtests import execute, setup, start
|
||||
from tests.test_backtests import execute, setup
|
||||
from tests.test_catalog import SCOPE, prepare, sync
|
||||
from tests.test_catalog import catalog as catalog_fixture
|
||||
|
||||
@@ -120,7 +120,7 @@ def test_bounded_sampling_and_repeated_placeholders():
|
||||
expand(expression, values, "all", 100)
|
||||
|
||||
|
||||
async def test_template_version_expansion_preview_and_backtest(app, logged_in, research_input):
|
||||
async def test_template_version_expansion_direct_backtest_and_idempotency(app, logged_in, research_input):
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": template()})
|
||||
assert saved.status_code == 201, saved.text
|
||||
asset = saved.json()
|
||||
@@ -130,7 +130,7 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
assert generated.status_code == 201, generated.text
|
||||
experiment = generated.json()
|
||||
assert len(experiment["candidates"]) == 2
|
||||
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
modified = template()
|
||||
modified["expression"] = "-rank({field})"
|
||||
response = await logged_in.put(
|
||||
@@ -147,10 +147,24 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
)
|
||||
).status_code == 409
|
||||
platform, lane = await setup(app)
|
||||
preview = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})
|
||||
assert preview.status_code == 201, preview.text
|
||||
assert not platform.posts
|
||||
run = await start(logged_in, preview.json(), "research-stage-one")
|
||||
from app.models import BacktestPreview
|
||||
|
||||
url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
|
||||
request = {"candidate_ids": ["c2"], "idempotency_key": "template-confirmation"}
|
||||
result = await logged_in.post(url, json=request)
|
||||
assert result.status_code == 202, result.text
|
||||
run = result.json()
|
||||
assert run["total"] == 1
|
||||
assert run["source"]["kind"] == "template"
|
||||
assert run["source"]["input_snapshot_ids"] == [research_input["id"]]
|
||||
retry = await logged_in.post(url, json=request)
|
||||
assert retry.status_code == 202 and retry.json()["backtest_run_id"] == run["backtest_run_id"]
|
||||
conflict = await logged_in.post(url, json={**request, "candidate_ids": ["c1"]})
|
||||
assert conflict.status_code == 409
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
|
||||
assert lane.wake.is_set()
|
||||
await execute(app, lane, run["backtest_run_id"])
|
||||
results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
|
||||
aid = results["items"][0]["alpha_id"]
|
||||
@@ -161,22 +175,34 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
|
||||
assert old["backtest_run_ids"] == [run["backtest_run_id"]]
|
||||
|
||||
|
||||
async def test_invalid_fields_and_unknown_operators_never_start(app, logged_in, research_input):
|
||||
async def test_template_generation_does_not_require_field_operator_or_settings_evidence(app, logged_in, research_input):
|
||||
from sqlalchemy import delete
|
||||
|
||||
async with app.state.sessions.begin() as db:
|
||||
await db.execute(delete(CatalogResource))
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["variables"]["field"]["values"] = ["other_field"]
|
||||
assert (await logged_in.post("/api/v1/research/experiments", json=body)).status_code == 422
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["expression"] = "made_up({field})"
|
||||
body["template"]["expression"] = "made_up({field}) + vec_avg(TEST_FIN_001)"
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 201, response.text
|
||||
eid = response.json()["id"]
|
||||
assert (await logged_in.post(f"/api/v1/research/experiments/{eid}/preview", json={})).status_code == 422
|
||||
experiment = response.json()
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
assert not {"operators_snapshot", "settings_snapshot", "field_availability"} & experiment["evidence"].keys()
|
||||
assert (await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})).status_code == 201
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression", ["rank({field}", "rank({field},,)", "x = {field}"])
|
||||
async def test_template_syntax_errors_reject_whole_collection(app, logged_in, research_input, expression):
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["expression"] = expression
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "语法错误" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
|
||||
|
||||
async def test_import_preview_conflict_and_explicit_commit(logged_in):
|
||||
legacy = {
|
||||
"name": "legacy",
|
||||
@@ -367,7 +393,7 @@ def test_actual_cnhk_setting_choice_nesting_is_supported():
|
||||
)
|
||||
|
||||
|
||||
async def test_published_input_does_not_override_conflicting_field_evidence(app, logged_in, research_input):
|
||||
async def test_template_candidates_ignore_conflicting_field_evidence(app, logged_in, research_input):
|
||||
async with app.state.sessions.begin() as db:
|
||||
await ResearchMetadata(db).publish(
|
||||
"availability-fixture",
|
||||
@@ -382,12 +408,11 @@ async def test_published_input_does_not_override_conflicting_field_evidence(app,
|
||||
experiment = (
|
||||
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
|
||||
).json()
|
||||
assert experiment["candidates"][0]["validation"]["status"] == "needs_review"
|
||||
assert experiment["candidates"][1]["validation"]["status"] == "valid"
|
||||
assert all("validation" not in c for c in experiment["candidates"])
|
||||
denied = await logged_in.post(
|
||||
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c1"]}
|
||||
)
|
||||
assert denied.status_code == 422
|
||||
assert denied.status_code == 201
|
||||
duplicate = await logged_in.post(
|
||||
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c2", "c2"]}
|
||||
)
|
||||
@@ -526,3 +551,131 @@ async def test_model_cannot_append_preparation_references_to_fixed_inputs(app, l
|
||||
assert response.status_code == 422 and "不能改变" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert not await db.scalar(select(ResearchAsset).where(ResearchAsset.name == "untrusted"))
|
||||
|
||||
|
||||
async def test_template_definitions_bind_selected_fields_without_mutating_asset(logged_in, research_input):
|
||||
content = template()
|
||||
content["expression"] = "rank({field}) + rank({field})"
|
||||
content["variables"]["field"] = {"kind": "field", "field_type": "MATRIX", "description": "横截面字段"}
|
||||
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
|
||||
assert saved.status_code == 201, saved.text
|
||||
asset = saved.json()
|
||||
assert asset["content"]["variables"]["field"] == {
|
||||
"kind": "field", "field_type": "MATRIX", "description": "横截面字段", "values": [],
|
||||
}
|
||||
body = expansion(research_input["id"], asset_id=asset["id"], version=1, mode="random", limit=2, seed=19)
|
||||
body.pop("template")
|
||||
first = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
second = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert first.status_code == second.status_code == 201, first.text
|
||||
assert first.json()["candidates"] == second.json()["candidates"]
|
||||
assert len(first.json()["candidates"]) == 2
|
||||
for candidate in first.json()["candidates"]:
|
||||
field = candidate["bindings"]["field"]
|
||||
assert research_input["field_types"][field] == "MATRIX"
|
||||
assert candidate["expression"] == f"rank({field}) + rank({field})"
|
||||
stored = (await logged_in.get(f"/api/v1/research/assets/{asset['id']}")).json()
|
||||
assert stored["content"] == asset["content"]
|
||||
|
||||
|
||||
async def test_empty_template_domains_fail_expansion_with_actionable_errors(logged_in, research_input):
|
||||
body = expansion(research_input["id"])
|
||||
body["template"]["variables"]["field"] = {"kind": "field", "field_type": "GROUP"}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "没有匹配的 GROUP 字段" in response.text
|
||||
body["template"]["expression"] = "ts_mean(TEST_FIN_001, {window})"
|
||||
body["template"]["variables"] = {"window": {"kind": "integer", "description": "时间窗口"}}
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "变量 window 缺少候选取值" in response.text
|
||||
|
||||
|
||||
async def test_native_bot_creates_template_and_immutable_version(app, logged_in):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.ai.capabilities import ToolContext
|
||||
from app.ai.tools import CAPABILITIES
|
||||
from app.business import Business
|
||||
|
||||
async with app.state.sessions.begin() as db:
|
||||
ctx = ToolContext(Business(db))
|
||||
content = template()
|
||||
content["variables"]["field"].pop("values")
|
||||
created = await CAPABILITIES["create_research_template"].invoke(ctx, content)
|
||||
content["description"] = "修订后的研究解释"
|
||||
args = {"asset_id": created["id"], "version": 1, "content": content}
|
||||
updated = await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert updated["version"] == 2
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
await CAPABILITIES["create_research_template_version"].invoke(ctx, args)
|
||||
assert conflict.value.status_code == 409
|
||||
old = (await logged_in.get(f"/api/v1/research/assets/{created['id']}?version=1")).json()
|
||||
assert old["content"]["description"] == "测试经济假设"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate_settings", [{"region": "EUR"}, {"universe": "TOP1000"}, {"delay": 0}])
|
||||
async def test_preparation_scope_must_still_match_candidate_settings(app, logged_in, research_input, candidate_settings):
|
||||
body = expansion(research_input["id"])
|
||||
body["input_ids"] = []
|
||||
body["preparation_refs"] = [{"id": research_input["preparation_id"], "version": research_input["preparation_version"]}]
|
||||
body["settings"].update(candidate_settings)
|
||||
response = await logged_in.post("/api/v1/research/experiments", json=body)
|
||||
assert response.status_code == 422 and "输入快照与研究范围不一致" in response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
|
||||
|
||||
|
||||
async def test_template_contract_rejects_removed_scope(logged_in):
|
||||
from app.research.workspace_contracts import TemplateSpec
|
||||
|
||||
content = template() | {"scope": SCOPE}
|
||||
response = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": content})
|
||||
assert response.status_code == 422, response.text
|
||||
assert "scope" in response.text
|
||||
assert "scope" not in TemplateSpec.model_json_schema()["properties"]
|
||||
|
||||
|
||||
async def test_template_start_checks_selection_and_ignores_old_row_validation(app, logged_in, research_input):
|
||||
from app.models import BacktestPreview
|
||||
|
||||
experiment = (await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
row = await db.get(ResearchExperiment, experiment["id"])
|
||||
row.candidates = [{**c, "validation": {"status": "needs_review", "syntax": [], "types": [],
|
||||
"availability": ["历史字段未核实"]}} for c in row.candidates]
|
||||
await setup(app)
|
||||
url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
|
||||
for ids in [[], ["unknown"], ["c1", "c1"]]:
|
||||
response = await logged_in.post(url, json={"candidate_ids": ids, "idempotency_key": "confirm-old"})
|
||||
assert response.status_code == 422, response.text
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
response = await logged_in.post(url, json={"candidate_ids": ["c1", "c2"], "idempotency_key": "confirm-old"})
|
||||
assert response.status_code == 202 and response.json()["total"] == 2
|
||||
retry = await logged_in.post(url, json={"candidate_ids": ["c2", "c1"], "idempotency_key": "confirm-old"})
|
||||
assert retry.status_code == 202 and retry.json()["backtest_run_id"] == response.json()["backtest_run_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["syntax", "scope", "archived", "disconnected"])
|
||||
async def test_template_start_rejects_unusable_collection_without_partial_writes(app, logged_in, research_input, change):
|
||||
from app.models import Account, BacktestPreview
|
||||
|
||||
experiment = (await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))).json()
|
||||
async with app.state.sessions.begin() as db:
|
||||
row = await db.get(ResearchExperiment, experiment["id"])
|
||||
if change == "syntax":
|
||||
row.candidates = [{**c, "expression": "rank("} for c in row.candidates]
|
||||
elif change == "scope":
|
||||
row.candidates = [{**c, "settings": {**c["settings"], "region": "EUR"}} for c in row.candidates]
|
||||
elif change == "archived":
|
||||
row.archived = True
|
||||
else:
|
||||
(await db.get(Account, 1)).connection_status = "disconnected"
|
||||
app.state.runner.backtests.wake.clear()
|
||||
response = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/backtest",
|
||||
json={"candidate_ids": ["c1"], "idempotency_key": "invalid"})
|
||||
assert response.status_code == (422 if change in ("syntax", "scope") else 409), response.text
|
||||
assert not app.state.runner.backtests.wake.is_set()
|
||||
async with app.state.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 0
|
||||
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Retire template scope once in storage, without a runtime compatibility path."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
Account,
|
||||
ResearchAsset,
|
||||
ResearchExperiment,
|
||||
ResearchFlowRun,
|
||||
ResearchRequest,
|
||||
ResearchRevision,
|
||||
ResearchStepRun,
|
||||
)
|
||||
from app.research.workspace_contracts import FeatureSpec, TemplateSpec
|
||||
|
||||
|
||||
def test_remove_template_scope_preserves_execution_scopes_and_all_versions(tmp_path, monkeypatch):
|
||||
database = tmp_path / "template-scopes.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
|
||||
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setenv("WQ_EMAIL", "")
|
||||
monkeypatch.setenv("WQ_PASSWORD", "")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
config = Config(str(root / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(root / "migrations"))
|
||||
command.upgrade(config, "0022")
|
||||
engine = sa.create_engine(f"sqlite:///{database}")
|
||||
scope = {"instrument_type": "EQUITY", "region": "EUR", "universe": "TOP2500", "delay": 1}
|
||||
clean = {"name": "template", "expression": "rank({scope})", "description": "scope is also a variable name",
|
||||
"variables": {"scope": {"kind": "field", "field_type": "MATRIX", "values": []}}}
|
||||
old = {**clean, "scope": scope}
|
||||
feature = {"name": "feature", "hypothesis": "test", "template": old}
|
||||
clean_feature = {**feature, "template": clean}
|
||||
payload = {"template": {"id": "a", "version": 1, "content": old}, "feature": {"content": feature},
|
||||
"inputs": [{"scope": scope, "field_ids": ["close"]}], "scope": scope,
|
||||
"bindings": {"scope": "close"}, "items": [old, None, "unchanged"]}
|
||||
expected = {**payload, "template": {**payload["template"], "content": clean},
|
||||
"feature": {"content": clean_feature}, "items": [clean, None, "unchanged"]}
|
||||
with Session(engine) as db, db.begin():
|
||||
db.add(Account(id=1))
|
||||
db.add_all([ResearchAsset(id="a", kind="template", name="template", version=501),
|
||||
ResearchAsset(id="b", kind="feature", name="feature", version=2)])
|
||||
db.flush()
|
||||
db.add_all([ResearchRevision(asset_id="a", version=i, content={**old, "scope": scope if i % 2 else None},
|
||||
provenance=payload) for i in range(1, 502)])
|
||||
db.add_all([ResearchRevision(asset_id="b", version=i, content=feature, provenance=payload) for i in (1, 2)])
|
||||
db.add(ResearchExperiment(id="experiment", name="test", kind="template", hypothesis="test",
|
||||
inputs=payload["inputs"], parents=[payload], candidates=[{"settings": scope}], evidence=payload))
|
||||
db.add(ResearchFlowRun(id="flow", request_id="request", name="test", definition=payload, authorization=payload))
|
||||
db.flush()
|
||||
db.add(ResearchStepRun(id="step", run_id="flow", node_id="expand", round=1, output=payload))
|
||||
db.add(ResearchRequest(id="request", account_id=1, operation="create_research_template",
|
||||
idempotency_key="key", digest="a" * 64, business_id="a", response=payload))
|
||||
# Include every stored column in the comparison, not just the removed key.
|
||||
tables = {name: sa.Table(name, sa.MetaData(), autoload_with=engine) for name in (
|
||||
"research_revisions", "research_experiments", "research_flow_runs", "research_step_runs", "research_requests",
|
||||
)}
|
||||
def snapshots():
|
||||
with engine.connect() as db:
|
||||
return {name: [dict(row) for row in db.execute(sa.select(table).order_by(*table.primary_key.columns)).mappings()]
|
||||
for name, table in tables.items()}
|
||||
before = snapshots()
|
||||
wanted = {name: [dict(row) for row in rows] for name, rows in before.items()}
|
||||
for row in wanted["research_revisions"]:
|
||||
row.update(content=clean if row["asset_id"] == "a" else clean_feature, provenance=expected)
|
||||
wanted["research_experiments"][0].update(evidence=expected, parents=[expected])
|
||||
wanted["research_flow_runs"][0].update(definition=expected, authorization=expected)
|
||||
wanted["research_step_runs"][0].update(output=expected)
|
||||
wanted["research_requests"][0].update(response=expected)
|
||||
command.upgrade(config, "head")
|
||||
assert snapshots() == wanted
|
||||
for row in wanted["research_revisions"]:
|
||||
(TemplateSpec if row["asset_id"] == "a" else FeatureSpec).model_validate(row["content"])
|
||||
# Downgrade does not invent the deleted metadata; re-upgrade is idempotent.
|
||||
command.downgrade(config, "0022")
|
||||
assert snapshots() == wanted
|
||||
command.upgrade(config, "head")
|
||||
assert snapshots() == wanted
|
||||
command.check(config)
|
||||
engine.dispose()
|
||||
+24
-7
@@ -1,6 +1,17 @@
|
||||
# Independent production configuration; do not merge with compose.yaml.
|
||||
name: wq-alpha-production
|
||||
|
||||
x-observability-labels: &observability-labels
|
||||
observability.logs: "true"
|
||||
observability.project: "wq-alpha"
|
||||
observability.env: "production"
|
||||
|
||||
x-container-logging: &container-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
x-backend: &backend
|
||||
image: wq-alpha-production-backend:${DEPLOY_TAG:-local}
|
||||
build:
|
||||
@@ -21,13 +32,15 @@ x-backend: &backend
|
||||
AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096}
|
||||
AI_TIMEOUT: ${AI_TIMEOUT:-180}
|
||||
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||
networks:
|
||||
- default
|
||||
- database
|
||||
# Reach the remote PostgreSQL endpoint through the default network's egress.
|
||||
|
||||
services:
|
||||
backend:
|
||||
<<: *backend
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "backend"
|
||||
logging: *container-logging
|
||||
# Migration is run separately with all application writers stopped.
|
||||
command: [uvicorn, 'app.main:create_app', --factory, --host, 0.0.0.0, --port, '8000', --workers, '1', --proxy-headers]
|
||||
healthcheck:
|
||||
@@ -40,10 +53,18 @@ services:
|
||||
restart: unless-stopped
|
||||
migrate:
|
||||
<<: *backend
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "migrate"
|
||||
logging: *container-logging
|
||||
profiles: [jobs]
|
||||
command: [alembic, upgrade, head]
|
||||
restart: 'no'
|
||||
web:
|
||||
labels:
|
||||
<<: *observability-labels
|
||||
observability.service: "web"
|
||||
logging: *container-logging
|
||||
image: wq-alpha-production-web:${DEPLOY_TAG:-local}
|
||||
build:
|
||||
context: .
|
||||
@@ -66,10 +87,6 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
database:
|
||||
external: true
|
||||
name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network}
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
+29
-14
@@ -1,6 +1,8 @@
|
||||
# Gitea 生产部署
|
||||
|
||||
本方案在目标 Linux 服务器上由已有 Gitea Runner 构建并启动 Docker Compose,复用已有 PostgreSQL。入口绑定 `127.0.0.1:8112`,由宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||
本方案使用两台服务器:A 服务器托管 Gitea 和项目仓库;B 服务器运行 `tencent-prod-runner`,拉取 A 上的 `prod` 分支代码,在 B 上构建镜像并启动 Docker Compose。工作流通过标签 `tencent-prod` 选择该运行器。
|
||||
|
||||
PostgreSQL 位于独立数据库服务器或云数据库,B 上的应用通过 `DATABASE_URL` 访问它,不依赖同机数据库容器或外部 Docker 网络。应用入口绑定 **B 服务器**的 `127.0.0.1:8112`,由 B 的宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。
|
||||
|
||||
## 1. 填写配置和数据库账号密码
|
||||
|
||||
@@ -8,22 +10,23 @@
|
||||
|
||||
| 位置 | 名称 | 填写内容 |
|
||||
| --- | --- | --- |
|
||||
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` |
|
||||
| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@远程数据库域名或IP:端口/数据库名`,地址须能从 B 的应用容器访问 |
|
||||
| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 |
|
||||
| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 |
|
||||
| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 |
|
||||
| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 |
|
||||
| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` |
|
||||
| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` |
|
||||
| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` |
|
||||
| Variables(可选) | `MCP_ENABLED` | 设置 `true` 启用 MCP;未配置时默认关闭 |
|
||||
|
||||
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。
|
||||
端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。原有 `DATABASE_NETWORK` 不再使用,无需在 B 上创建同名外部网络。
|
||||
|
||||
已配置的 `PROD_SSH_KEY`、`PROD_HOST`、`PROD_PORT`、`PROD_USER`、`PROD_KNOWN_HOSTS` 可以继续保留供远程运维使用。本流程由 B 的 Runner 直接部署,不读取这些 SSH Secrets;镜像在 B 本机构建和使用,也不读取 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`。
|
||||
|
||||
数据库连接串示例(实际填写时替换示例值):
|
||||
|
||||
```text
|
||||
postgresql+asyncpg://wq_user:YOUR_PASSWORD@postgresql:5432/wq_alpha
|
||||
postgresql+asyncpg://wq_user:YOUR_PASSWORD@db.example.com:5432/wq_alpha
|
||||
```
|
||||
|
||||
生成加密密钥:
|
||||
@@ -34,7 +37,9 @@ python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_
|
||||
|
||||
`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。
|
||||
|
||||
在现有 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。确认网络存在、数据库别名可解析、PostgreSQL 允许该 Docker 网络访问。应用使用异步驱动 `asyncpg`;远程数据库或强制 TLS 的实例需另外按该实例的 TLS 配置调整连接参数,此模板默认同机 Docker 网络。
|
||||
在远程 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。数据库安全组、白名单或防火墙须允许 B 的实际出口地址访问数据库端口,且 B 的应用容器须能解析并访问数据库地址;使用私网地址时先确保 B 与数据库的私网路由连通。不要在连接串中沿用只在 A 上可解析的数据库容器名,也不要使用 `localhost` 或 `127.0.0.1` 指向远程数据库。
|
||||
|
||||
应用使用异步驱动 `asyncpg`;强制 TLS 或自定义 CA 的实例需按该实例要求配置兼容的连接参数及证书,不应直接假定其控制台提供的其他驱动连接串可用。部署脚本会在停止旧服务前,从 B 的后端容器进行真实数据库连接预检。
|
||||
|
||||
如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。
|
||||
|
||||
@@ -42,19 +47,27 @@ WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面
|
||||
|
||||
## 2. 配置 Gitea Runner
|
||||
|
||||
沿用已成功部署 `zhixing-system` 的运行器标签 **`ubuntu-latest`**,无需注册新的 `wq-production` 运行器。该运行器的执行环境须能通过 Docker CLI/Compose 操作同一台 1Panel 服务器的 Docker daemon;可以复用现有 Docker 连接方式,不强制 host 模式。
|
||||
使用 B 服务器上已注册的 **`tencent-prod-runner`**,工作流的 `runs-on` 填写其标签 **`tencent-prod`**。只有 B 上用于生产部署的运行器应持有此标签。
|
||||
|
||||
运行器的执行环境须通过 Docker CLI/Compose 操作 **B 宿主机的 Docker daemon**。可以沿用现有 host 或 Docker 执行模式:host 模式需要执行用户有权访问 B 的 Docker;容器模式需要部署步骤所在的作业容器能够访问 B 的 Docker socket(通常是 `/var/run/docker.sock`),且具有 Docker CLI/Compose。只给 Runner 管理容器挂载 socket 并不能证明作业容器内也可访问;不要将部署命令连接到 A 的远程 Docker 或临时 Docker-in-Docker 实例。
|
||||
|
||||
Runner 注册的 Gitea 地址及仓库克隆地址必须能从 B 和作业执行环境访问,应使用 A 的实际域名或可达 IP;不能使用指向 A 本机的 `localhost`、`127.0.0.1` 或仅在 A 的 Docker 网络内可解析的容器名。
|
||||
|
||||
执行环境需要 Git、Bash、Node.js 20(checkout v4)以及支持 `up --wait --wait-timeout` 的 Docker Compose。本流程不需要 `flock` 或预建 `/opt/wq-alpha`。服务器需能访问 checkout action、基础镜像仓库和依赖源。
|
||||
|
||||
在仓库启用 Actions,`main` push 或手动运行会部署。Secrets 通过部署步骤的环境变量注入,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。
|
||||
在仓库启用 Actions,向 **`prod`** push 会部署;手动运行也须选择 **`prod`**,选择其他分支时本版工作流会跳过部署任务。Checkout 使用本次触发的提交,构建和迁移均在 B 的执行环境完成,无需 A 通过 SSH 再复制代码到 B。
|
||||
|
||||
本次修改仅在 `prod` 分支。`main` 中的旧工作流仍配置为 `main` push → `ubuntu-latest`,不会随 `prod` 的修改自动停用;如需停用旧部署,须另行修改 `main` 的工作流。
|
||||
|
||||
Secrets 仍在 A 的 Gitea 仓库设置中管理,通过部署步骤的环境变量注入 B 上的作业,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 B 的 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。
|
||||
|
||||
如果 Runner 被强制终止或 Docker 断连,可能留下锁容器。先确认没有本项目部署正在执行,再手动运行 `docker rm wq-alpha-production-deploy-lock` 后重试。不要在部署进行时删除锁。
|
||||
|
||||
## 3. 配置反向代理并首次运行
|
||||
|
||||
为 `PUBLIC_ORIGIN` 对应域名配置 HTTPS,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。代理须运行在宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||
将 `PUBLIC_ORIGIN` 对应域名解析到 **B 服务器**,在 B 配置 HTTPS 反向代理,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。此回环地址只对 B 本机有效;A 上的代理不能通过它访问 B。代理须运行在 B 的宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。
|
||||
|
||||
首次部署建议在 Gitea Actions 页面手动运行工作流。需要在服务器排障运行时,须先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||
首次部署建议在 Gitea Actions 页面选择 **`prod`** 分支手动运行工作流,确认任务由 `tencent-prod-runner` 接收。需要在服务器排障运行时,在 **B 服务器的 `prod` 检出目录**中先从安全渠道将上表必填配置注入当前进程环境,再执行:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-production.sh
|
||||
@@ -62,6 +75,8 @@ bash scripts/deploy-production.sh
|
||||
|
||||
固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。
|
||||
|
||||
从旧服务器切换时,若旧实例仍连接同一生产数据库,须在 B 上执行迁移前停止旧实例及其定时任务。脚本和部署锁只管理 B 上的容器,不会停止 A 上的旧应用,也不能协调两台服务器对同一数据库的迁移和写入。
|
||||
|
||||
首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。
|
||||
|
||||
## 4. 升级、备份和失败处理
|
||||
@@ -72,7 +87,7 @@ bash scripts/deploy-production.sh
|
||||
|
||||
如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。
|
||||
|
||||
以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||
以下排查命令在 **B 服务器**运行,不依赖凭据环境变量(可能含业务信息的日志请勿公开):
|
||||
|
||||
```bash
|
||||
docker ps -a --filter label=com.docker.compose.project=wq-alpha-production
|
||||
@@ -84,13 +99,13 @@ docker logs --tail=100 wq-alpha-production-web-1
|
||||
|
||||
## 配置依据
|
||||
|
||||
采用 compound 经验 1 中的生产/开发隔离、显式外部网络、必填凭据、固定项目名、独立迁移和部署健康检查。未照搬旧项目端口、业务 Job 或卷权限初始化:本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||
生产/开发配置隔离,使用必填凭据、固定项目名、独立迁移和部署健康检查。生产服务使用 Compose 默认网络互通,并通过该网络访问远程 PostgreSQL;无需预建数据库 Docker 网络。本应用后端不写 named volume,已有镜像使用 UID 10001。
|
||||
|
||||
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 外部网络](https://docs.docker.com/reference/compose-file/networks/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 标签](https://gitea.com/gitea/runner/src/branch/main/README.md)。
|
||||
凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 网络](https://docs.docker.com/compose/how-tos/networking/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 2.x 标签](https://docs.gitea.com/runner/2/labels/)、[Gitea 分支上下文](https://docs.gitea.com/usage/actions/actions-variables/)。
|
||||
|
||||
## 5. 1Panel 夜间全量目录同步
|
||||
|
||||
在 1Panel 的计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
||||
在 **B 服务器**的 1Panel 计划任务中建立 Shell 脚本任务,执行周期由 1Panel 设置,例如每天深夜执行。每次命令明确指定一个范围:
|
||||
|
||||
```bash
|
||||
docker exec wq-alpha-production-backend-1 \
|
||||
|
||||
@@ -43,3 +43,17 @@ SUE 类方向的可迁移问题是“盈利变化相对于自身历史波动是
|
||||
dateCoverage 只是一项元数据,不告诉你缺失发生在哪几年,也不完整表达横截面质量;VECTOR 非空容器也可能没有有效元素。不要把社区的 40%/60%/80% 建议阈值当官方规则。检查逐年、逐区覆盖及事件时点,比固定门槛更重要。
|
||||
|
||||
来源:[【平台新功能】字段新特征:datecoverage的介绍、应用与勘误。](https://support.worldquantbrain.com/hc/en-us/community/posts/37581194344215)(发帖 2026-01-10,社区经验)。
|
||||
|
||||
## 2026-09-18 补充:代理变量与样本边界
|
||||
|
||||
[搜索量转新闻密度的注意力反转帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43541109631895)(2026-09-16)用新闻事件密度代理搜索关注,作者报告 GBR 与 EUR 表现不同,EUR 变体 Fitness 均低于 1。新闻供给不等于投资者主动搜索,代理替换可能改变机制,应比较原代理、新闻量单腿及价格反转对照;这不是已验证的论文等价复现。
|
||||
|
||||
[MAX 与彩票偏好帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43508107778711)(2026-09-15)报告滚动极端涨幅除以自身波动后,Sharpe 从 0.36 到 0.75,最大回撤从 48.96% 到 5.69%;缩短为 5 日窗口虽进一步提高指标,作者也承认与短期反转重叠。可保留“极值相对自身波动”的待验证假设,但窗口更短、更强不等于彩票机制更纯。本轮未复核论文、借券成本或原始回测,也不将标题中的“完整复现”当作已验证结论。
|
||||
|
||||
[DEU 内部人信念强度帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43178926004887)(2026-09-02,本轮补录)提醒:稀疏事件填零后,大块相同排名可能代表没有事件,不能解释为经济信念中等。作者还报告有效持仓主要在 2018–2023,而较早年份为零;应以实际可投资区间评估结果。比较滚动和与均值时须核对有效观测数和缺失处理;完整固定窗口下两者只差常数,不能笼统断言换成均值必然丢失事件信息。原帖的 tail 阈值与性能改善仅是单例,不推广为稀疏数据通用模板。
|
||||
|
||||
## 2026-09-25 补充:非线性变换与覆盖需要分开检验
|
||||
|
||||
[EUR 财报电话会情绪概率差](https://support.worldquantbrain.com/hc/en-us/community/posts/43620895448343)(2026-09-20)报告 signed power 降相关,并描述早期稀疏截面造成权重集中。随附评论明确指出另一字段的成功不能证明“概率差 + signed_power”有效;应固定其余设置分别对照回填窗、覆盖和 truncation,拆开表达式变换与股票池变化。原帖指标属于作者自报,未复现。
|
||||
|
||||
[ASI 借券费率方差案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624312881943)(2026-09-20)比较方差与标准差版本。即使同一有效样本下平方保持非负值的截面排序,后续线性缩放、中性化或权重处理也可能得到不同持仓,不能由“同一排序”推出整条策略等价。作者由少数 checks 推断的 robust 比率约 0.9 仅是其样本观察,不写成通用平台门槛;每次仍需读取实际 value、limit 和状态。
|
||||
|
||||
@@ -63,3 +63,19 @@ ASI/TOP500 的 risk70 模板使用时间操作后按 exchange 中性化。来源
|
||||
## 10. 当前优先级
|
||||
|
||||
已有九月四张卡覆盖 SNR、机构拥挤、Wikipedia 关注和订单流。新补充方向优先选择“极性配对、同行相对位置、明确口径的实际/预测偏差”中你能取得完整数据的少量假设。优先级属于本库研究建议,未实际运行或比较这些方向的收益。
|
||||
|
||||
## 2026-09-18 补充:差分、正交化与类别编码
|
||||
|
||||
### 同源做差不会保证降低 PC
|
||||
|
||||
[同源信号与噪声对冲](https://support.worldquantbrain.com/hc/en-us/community/posts/43501264459799)(2026-09-15)比较两条弱信号做差和两条强信号做差;[后续讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43536370604567)(2026-09-16)要求拆开两腿验证,但其中“做差一定会让 PC 下降”的说法不成立。数学反例:令 P 与 N 独立且方差相同,A=P+N、B=N,则 A 与 P 的相关系数为 1/√2,而 A−B 与 P 的相关系数为 1。对某些生产池成分,相关性反而会上升。
|
||||
|
||||
保留的实验是 A、B、A−B 分别比较收益、成本、覆盖和与已有池的相关性;两腿都弱也不自动证明共享噪声被成功剥离,需要样本外和稳定性证据。[周频量价概率正交化案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43500671638551)(2026-09-15)自报 Max Prod Corr 在加入运营效率控制后从 0.6806 升至 0.7068,再加国家中性化降至 0.6681。与指定控制变量正交,不等于与整个生产池低相关;作者还报告覆盖及收益损失,应把这些变化同时列入对照,不将单个检查过线称为新机制成立。
|
||||
|
||||
### 分析师差分先对齐经济口径
|
||||
|
||||
[analyst10 预期差异帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43524498271895)(2026-09-16)提出近远期预期差、上调与下调差等候选。可取的是同单位、同口径、只改变一个经济维度的配对方式;覆盖人数与预测 surprise 不能直接相减,已表示净修订的字段也不能未经语义核对再机械差分。120 日回填是作者参数,须另外检查数据可得时点、陈旧观测及两腿实际覆盖,不当作字段的通用配置。
|
||||
|
||||
### 类别编号不是有量纲的数值
|
||||
|
||||
[JPN 另类行业分类协方差帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43308165478935)(2026-09-07,本轮补录)把两套类别整数标签做时间协方差,报告低换手,同时承认解释偏弱和 OS 检查 PENDING。任意置换簇编号可能改变该协方差,而经济分类没有改变,因此不能直接将结果解释为“分类分歧强度”。至少应检验标签重编码不变性,并与基于类别成员关系的一致性度量比较;不同聚类方法也不自动意味着统计独立。本轮没有复现其回测。
|
||||
|
||||
@@ -31,3 +31,11 @@ FastExpr 已能表达的简单机制,先用它建立基线。需要自定义
|
||||
来源:[[Python Alpha 挑战赛]Python Alpha两阶段筛选法:FastExpr快速验证 + Python深度优化](https://support.worldquantbrain.com/hc/en-us/community/posts/41135490599063)(发帖 2026-06-11,社区经验);[从 Fast 翻译到 Python Alpha 别急着炫技:截面/时序错位是头号杀手,事件漂移和中性化层级是隐形雷](https://support.worldquantbrain.com/hc/en-us/community/posts/42047259261335)(发帖 2026-07-18,社区经验);[【经验分享】Python Alpha 30名心得:从 FastExpr 到 Python,我用 17 个 alpha 趟出来的一条路](https://support.worldquantbrain.com/hc/en-us/community/posts/42519002511767)(发帖 2026-08-06,社区经验);[Python Alpha 平台限制粗略小结,请大佬指正](https://support.worldquantbrain.com/hc/en-us/community/posts/40742727142679)(发帖 2026-05-26,社区经验)。
|
||||
|
||||
本次没有安装或运行 BRAIN Labs Python SDK,所以这里只提供官方入口与迁移验证设计,不提供宣称可直接运行的 SDK 脚本。
|
||||
|
||||
## 2026-09-25 补充:频域与 PCA 实现先验算
|
||||
|
||||
[FFT 复现纠错帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43640747372695)(2026-09-21)指出一个可直接验算的恒等式:对同一输入 x 的互补频带,slow + fast = x,因此 slow − x 与 −fast 是同一条腿,混合权重不会产生新信息。这只适用于所示互补分解及一致预处理,不能泛化为所有慢快信号必然共线。
|
||||
|
||||
原帖的其余“修正”仍需审查。对称 Hann 窗末端为零只能推出重构后的 slow[-1] + fast[-1] = 0,不能单凭这一点断言两个分量各自为零或必然衰减固定倍数。正文提倡前值填充,但修正版实际用窗口列均值填缺失;二者不是同一种经济假设。代码 `hist[-WIN:]` 在 WIN=65 时取 65 行,与注释宣称的 66 行也不一致。14–17 倍加速、泄漏比例和回测效果均为作者报告,本库未运行代码。
|
||||
|
||||
[PCA 特质残差反转帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43692787685015)(2026-09-23)可作为待验证的自估风险模型思路,但代码 `cs[H-1:] - cs[:-H+1]` 每项相减的累计索引间隔为 H−1,不能直接标成 H 日收益和;它还将非有限收益填零,需核对缺失、上市历史与股票池影响。作者报告 IND 有效而 EUR/USA/HKG 不佳,并将 PnL 相关 0.963 称作“恒等变换”;这是近似经验,非数学恒等。不得照搬其地区结论或部分正交化系数。
|
||||
|
||||
@@ -41,3 +41,19 @@ CSV 本身不保证并发原子性。需要记录任务身份、租约或领取
|
||||
可迁移的工程做法是先清点候选、小样本检查,再扩大批量;逐项记录源 Alpha、模拟引用和终态。`POST_ERROR` 不能一律视为已处理,未知受理结果须先对账,429 等临时错误与字段不支持多区域的兼容性错误应分别处理。评论补充保持原有 decay、pasteurization 等设置,避免把参数变化误归因于跨区域迁移;新的 universe 口径也要单独核对。
|
||||
|
||||
帖子开头声称批量重跑“一定能找到”可提交候选,但正文也明确完成不代表指标或提交资格保留。本库只保留后一边界;没有可提交结果也不足以单独证明原信号质量低,仍需排查覆盖、设置和区域适用性。
|
||||
|
||||
## 2026-09-18 补充:断点声明与代码行为要对照
|
||||
|
||||
[OS Alpha 批量重跑 RA 两脚本](https://support.worldquantbrain.com/hc/en-us/community/posts/43524503109655)(2026-09-16)声称支持续跑,但所附代码在 `simulate_single` 返回后无条件 `mark_done(k)`,而异常或缺少 Location 的路径可能返回 `None`。这会把未确认完成的任务记为已处理;应保留已受理引用及 UNKNOWN/待对账状态,不能仅靠 done 集合跳过。进度键也没有覆盖全部 settings,改动未入键参数后可能误复用旧进度。这些是静态阅读发现,没有运行脚本或验证平台端行为。
|
||||
|
||||
同帖还包含自动 PATCH Alpha 标签的步骤,因而不是纯读取工具。[另一篇 RA 说明](https://support.worldquantbrain.com/hc/en-us/community/posts/43410200668823)(2026-09-11)所述“不设置属性、不提交”不能自动套用到不同脚本。[AlphaSubmitter](https://support.worldquantbrain.com/hc/en-us/community/posts/43299585796247)(2026-09-07,本轮补录)宣传多轮自动重试,但没有给出足够的幂等或受理状态核对证据;POST 超时不能直接解释为服务端未创建,重发前需先对账。
|
||||
|
||||
[意见留言墙](https://support.worldquantbrain.com/hc/en-us/community/posts/42302637812503)本轮新可见评论提出登录过期导致界面状态丢失、回测次数与 Alpha 列表数量不同等问题。它们是用户反馈,不是已确认平台故障:先保存任务引用与界面筛选状态,再分别核对请求次数、失败/取消状态和结果对象数,避免仅凭两个总数推断丢数据。本轮没有复现相关 UI 行为。
|
||||
|
||||
## 2026-09-25 补充:缺失状态不能被默认值掩盖
|
||||
|
||||
[回测用量帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43058251853847#community_comment_43553064668183)(2026-09-17,本轮补齐)指出,没有匹配到当天活动记录时直接置 used=0,可能把日期错位或接口异常解释成额度充足。适合作为审查项:缺记录保留 UNKNOWN,再核对日期与响应;评论所述日界、固定上限和具体 API 行为未由本轮核验。
|
||||
|
||||
[ProdMemo 桥接帖评论](https://support.worldquantbrain.com/hc/en-us/community/posts/42585946296855#community_comment_43606520549399)(2026-09-19)提出旧缓存覆盖新记录的风险。合并时应保留数据类型、采集时间与来源范围,并核对部分结果是否覆盖完整结果;这是评论提供的待核审查点,本轮没有重新读取并运行桥接脚本,不能直接宣称已确认其实现缺陷。
|
||||
|
||||
[多 Agent 研究流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43623479962007)(2026-09-20)把字段证据、研究假设、实验结果与审计裁定分开,是可用的职责设计:字段存在不证明逻辑成立,回测返回结果不证明通过审计。它是作者的架构方案,不是多代理提高样本外表现的实证。
|
||||
|
||||
@@ -28,3 +28,9 @@
|
||||
[Grand Master 1v1 Advisory 通知](https://support.worldquantbrain.com/hc/en-us/community/posts/43320365312023)(2026-09-08 发布,0 条评论)介绍约 10 分钟答疑,称试运行预约窗口截至 9 月 17 日。可准备一个具体研究瓶颈和证据;本次没有访问预约服务、核对剩余名额或预约。
|
||||
|
||||
[关于第三方发布的论坛公告](https://support.worldquantbrain.com/hc/en-us/community/posts/43319649278359)(2026-09-08 发布,本次读正文及 2 条评论)强调平台内容的对外发布限制,涉及 Alpha、数据、文档及论坛文章。本库记录公告及来源,不作协议效力或个案法律判断;本轮仅本地整理,没有对外发布、推送仓库或改变权限。
|
||||
|
||||
## 2026-09-18 论坛增量
|
||||
|
||||
[ARC2026 社区积分活动通知](https://support.worldquantbrain.com/hc/en-us/community/posts/43498120902807)(2026-09-15)称,CN/HK 顾问按比赛结束时官方榜单统计,成功提交至少 2 个 ARC/RA Parent 可得 50 积分,至少 10 个可得 100 积分,取最高档、不叠加。通知明确这是社区配套活动,与比赛的美元奖池分开。本轮读取通知,未核实个人资格、积分到账或兑换比例,也未提交 Alpha。
|
||||
|
||||
上文 Grand Master 1v1 Advisory 原公布的 9 月 17 日预约窗口已过;截至本次整理,不应继续把它列为已确认开放的预约机会,本轮未核查是否续期。新出现的招聘、免费模型与云服务推广帖保留目录,不将促销额度、模型可用性和邀请收益写成研究结论。
|
||||
|
||||
@@ -26,3 +26,9 @@
|
||||
先排查数据中断与不可投资表现,再按同族/相关簇留代表,然后看同时回撤和成本后的组合增量。旧 Alpha 也可重新评估;但不能只因最近几天排名好就重仓。
|
||||
|
||||
为每次比较固定评价时段和费用设置,记录“原组合”“加入候选”“替换近似旧项”三组结果。正式配点/提交前再确认资格与生效时间。本次没有修改任何平台属性。
|
||||
|
||||
## 2026-09-18 补充:教学倍数与支付公式分开
|
||||
|
||||
[Theme 到 Osmosis 的说明帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43529198653719)(2026-09-16)区分质量因子的加成与每日美元报酬,并明确把“主题合并后再乘 Osmosis”“不分配按 1 倍”作为文章的补充计算口径;这不等于取得完整官方支付公式。[Base Payment 说明帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43561782887575)(2026-09-17)同样声明,四项得分归一化到 0–1 再等权平均只是教学假设。“交满适用配额即 Quantity 满分”也是该帖补充口径,本轮没有独立核实,不能据此计算个人收入或设置自动提交目标。
|
||||
|
||||
[两条高倍数 Alpha 的收入分享](https://support.worldquantbrain.com/hc/en-us/community/posts/43532701388183)(2026-09-16)把两天支付差异归因于提交组合及平均方式;缺少同条件对照,不能据此推导“少交必然更赚钱”或固定的 VF、Osmosis 收入门槛。保留的研究问题是:完整 Alpha 的适用资格、组合贡献和评价窗口是否一致。本文不新增已核实的乘数、资格或支付规则,也不把作者报告视为本次复现。
|
||||
|
||||
@@ -4,7 +4,7 @@ SPC 与“LLM 帮你写 FastExpr”不同:你提交投资论点 Prompt,平
|
||||
|
||||
## 9 月必须更新的输出格式
|
||||
|
||||
7 月中文教程仍写 ISIN|MIC 字典;最新官方提交页(9 月 6 日更新)要求**对象数组**。结构示意如下,尖括号为类型占位符,不能原样提交:
|
||||
7 月中文教程仍写 ISIN|MIC 字典;9 月 7 日首次核查的官方提交页已要求**对象数组**。2026-09-18 再读该页(页面更新 9 月 13 日),要求仍为对象数组。结构示意如下,尖括号为类型占位符,不能原样提交:
|
||||
|
||||
```text
|
||||
[
|
||||
@@ -20,9 +20,9 @@ SPC 与“LLM 帮你写 FastExpr”不同:你提交投资论点 Prompt,平
|
||||
]
|
||||
```
|
||||
|
||||
confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为预期实现收益的天数;return_prediction 表示预测收益率。官方示例用 0.045 表示 4.5%。务必核对证券标识与交易场所,不能用臆造 ID。
|
||||
confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为预期实现收益的天数;return_prediction 表示预期收益变动幅度。9 月 18 日复核的官方文字要求 return_prediction 为正,多空方向由 confidence_score 表示,不能再靠负 return_prediction 表示做空。官方示例用 0.045 表示 4.5%。务必核对证券标识与交易场所,不能用臆造 ID。
|
||||
|
||||
来源:[What do I need to submit?](https://support.worldquantbrain.com/hc/en-us/articles/41681208900759)(官方,更新 2026-09-06);旧格式来源:[【SPC 2026】一文看懂 Systematic Prediction Challenge,写提示词就能参加!](https://support.worldquantbrain.com/hc/en-us/community/posts/41801462727191)(发帖 2026-07-08,社区经验)。
|
||||
来源:[What do I need to submit?](https://support.worldquantbrain.com/hc/en-us/articles/41681208900759)(官方,本次页面更新 2026-09-13,读取 2026-09-18);旧格式来源:[【SPC 2026】一文看懂 Systematic Prediction Challenge,写提示词就能参加!](https://support.worldquantbrain.com/hc/en-us/community/posts/41801462727191)(发帖 2026-07-08,社区经验)。
|
||||
|
||||
## 已核实的规则
|
||||
|
||||
@@ -46,3 +46,9 @@ confidence_score 范围 -1 到 1,符号对应多空;investment_horizon 为
|
||||
社区“信号 5–7 天衰减”来自特定经验,不是所有 SPC 机制的固定半衰期。期限应根据来源更新节奏与前瞻验证决定。来源:[【SPC实战】信号衰减5-7天:数据源“时效性”比“全面性”更重要](https://support.worldquantbrain.com/hc/en-us/community/posts/42955680180247)(发帖 2026-08-24,社区经验)。
|
||||
|
||||
本次未调用 LLM 生成投资组合,未提交 Prompt、调整权重或参加比赛。
|
||||
|
||||
## 2026-09-18 补充:社区提交脚本仍需核对版本
|
||||
|
||||
[SPC 提交 API 抓包与自动化帖](https://support.worldquantbrain.com/hc/en-us/community/posts/43322480595991)(2026-09-08,本轮补录)示例仍将 `sampleOutput` 写为 `ISIN|MIC` 字典字符串,与本次官方要求的输出内容不一致。API 包装字段是否需要字符串编码和 Prompt 实际应输出什么,是两个问题;不能因抓包样例曾被接受,就将旧字典视为现行内容规范。
|
||||
|
||||
静态阅读还发现,该帖称已加入调权命令,但展示的命令解析只有登录检查、列表和提交;权重验证检查范围却未落实文中两位小数的声明。示例 POST 超时后没有受理状态对账,人工或自动重试可能造成重复提交。`weight=0` 在官方文字中是停止运行,不应按社区的“下线”表述推断对象被删除。这里只记录版本与恢复边界,未执行脚本、探测提交接口或调整权重。
|
||||
|
||||
@@ -18,3 +18,9 @@
|
||||
大规模搜索时特别检查:收益是否集中在某一年;低 PC 是否只来自缺失或不同日期;高 Sharpe 是否由不可投资的极端持仓产生;同一模板换字段是否仍是同一风险来源;回填是否把旧事件变成持续仓位。
|
||||
|
||||
本知识库中的研究方向没有经过上述实际回测终验,均是待研究候选。
|
||||
|
||||
## 2026-09-25 补充:冻结发生在看结果之前
|
||||
|
||||
[时间前推与冻结流程](https://support.worldquantbrain.com/hc/en-us/community/posts/43638422558103)(2026-09-21,社区方法建议)区分历史分年诊断、时间前推与前瞻观察。看过完整历史后再按年拆分,不能恢复被选择过程用掉的独立性。每个验证段开始前记录表达式、完整 settings、冻结时间和淘汰条件;改动生成新版本,保留失败段,汇总冻结后各段的日收益,而非只展示最好一段或平均各段 Sharpe。
|
||||
|
||||
若标签或持有期跨越分割点,隔离范围应依据实际重叠,而非机械等于最长回看窗口。冻结卡和影子观察是研究建议,不是平台提交规则,也不能据此把个人留出段称为平台隐藏 OS。本轮未复核文中外部论文或运行验证实验。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 来源、覆盖范围与验证记录
|
||||
|
||||
首次采集日期:2026-09-07;最近论坛同步:2026-09-11。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
||||
首次采集日期:2026-09-07;最近论坛同步:2026-09-25(有访问缺口)。下列初轮与扩展轮数字保留为历史记录,最新覆盖见文末周同步记录。时间边界按 Asia/Shanghai 的 2026-01-01 00:00 至整理时点;原始索引时间使用 UTC/带偏移的 ISO 8601。日历日简写通常取原来源 UTC 日期,未把它伪装成本地精确时刻。
|
||||
|
||||
## 邮箱
|
||||
|
||||
@@ -92,3 +92,33 @@ SPC 7 篇官方帮助页按 ID 直接读取;另通过支持站搜索取得 Pyt
|
||||
任务专用采集入口和状态位于本机 `.local/forum-sync/`(Git 忽略):`collect.py` 负责分页、限流退避、互斥和逐帖原子保存;`staged/` 保留去除 HTML 和凭据类字符串的文本;`candidate-baseline.json` 为采集候选,知识整理及验证通过后才复制为 `baseline.json` 并推进 `checkpoint.json`。后续采集比较该基线的正文及评论指纹,仍须实际筛选变化内容。`run.json` 保存本次计数和失败项;部分失败不得宣称全部成功。此状态只包含任务必要内容,不保存登录响应、Cookie 或会话令牌,也未推广为全局规则或共享脚本。
|
||||
|
||||
本轮验证:两个 CSV 各 629 个唯一帖子 ID,评论数合计 9,587;所有论坛引用均属于本次范围,122 篇帖子被知识条目引用,本地链接未发现缺失目标。新增知识内容及本地 JSON 状态的账号、密码字面值与 JWT 形式扫描通过。实际 API 采集通过;采集脚本语法检查通过,后续增量比较、失败恢复和限流退避尚未通过真实故障场景演练,不能将首次成功等同长期运行保证。
|
||||
|
||||
## 每周论坛同步:2026-09-18
|
||||
|
||||
本次实际采集时间为北京时间 10:11:39–10:16:36;这是执行记录,不将计划的 09:00 写成实际开始时间。认证返回 201,支持站 SSO 落地仍为 403,Community API 正常。18 页共有 1,771 篇可见帖子,排除 1,101 篇早于年初边界的帖子,范围内保留 670 篇;上次 629 个 ID 均仍可见。逐帖分页取得 9,601 条评论,670 个记录的实际取得数与列表计数均相等,零采集失败。
|
||||
|
||||
相对 9 月 11 日内容基线,新收录 41 篇:29 篇发表于上次采集结束后,12 篇的创建时间更早。后者只能称为本轮补录,无法仅凭列表差异判断此前为何不可见或漏收。这 41 篇共取得 5 条评论。旧帖留言墙 42302637812503 新可见 9 个评论 ID,其中 7 条创建时间早于上次采集、2 条发表于 9 月 12 日;其余既有评论文本指纹未见变化,也没有缺失评论 ID。评论总数增加 14 条,其中只有 7 条的创建时间晚于上次采集,不能将净增量全称为本周发表。
|
||||
|
||||
231 篇旧帖的更新时间变化,但只有两篇正文内容指纹变化:43320471694999 的 AI 合集新增 CNHKMCP QuantFlow 提示词入口,43030071187479 的新人指南新增第三方发布公告与 Osmosis 入口。逐一对照上周文本确认均为目录更新,没有据此扩充列表外旧帖知识。加上留言墙评论变化,共 3 篇既有帖出现内容或评论集合变化,其余仅同步元数据。
|
||||
|
||||
41 篇新收录帖已完成标题筛查,40 篇有可提取正文并完成主旨阅读,14 篇研究机制和 6 篇工程材料另做独立核验。43287990820375 的正文仅含 12 张图片、没有可提取文字;尝试读取全部 12 个官方图片地址均返回 403,因此未逐图核验,阅读状态记为 `metadata_only`,并在检查点保留待阅读项,不把标题中的夺冠经验或 21 个 OS Alpha 视为已读证据。新收录帖的 5 条评论及留言墙新可见的 9 条评论均已阅读。宣传、充值/邀请和缺少研究证据的收入展示保留索引,不机械扩写成研究方法。
|
||||
|
||||
知识更新包括:同源差分和正交化不保证降低生产相关性、类别编号协方差的编码依赖、分析师差分口径、代理变量及稀疏事件的有效样本边界;RA 脚本未知结果被标为完成的静态风险;支付教学假设与真实公式的区别;ARC 社区积分活动;按评价时间复盘的边界。所有社区回测数值仍为作者自报,本轮未运行或安装其代码。另通过官方 API 复核 SPC 提交说明 41681208900759(更新 2026-09-13),确认对象数组格式,并补充 return_prediction 正值与 confidence_score 多空方向的分工;没有访问提交接口或调整权重。
|
||||
|
||||
本轮继续比较全部范围内评论的文本指纹,没有假定帖子更新时间或评论数量能完全反映评论编辑。评论指纹针对去除 HTML、规范化空白后的文字,不覆盖纯排版、链接目标或图片替换;因此“未见评论文本编辑”不能解释为所有媒体与格式均未变化。上周文本保留在本机 `.local/forum-sync/previous-2026-09-11/`,新候选仍由验证后才推进到相应成功基线,图片访问缺口另行保留;状态与采集文本不包含认证响应或会话材料。没有同步邮件、改变平台属性、预约、报名、回测、发帖或推送仓库。
|
||||
|
||||
验证结果:两个 CSV 各有 670 个唯一帖子 ID,评论合计 9,601;采集记录及候选指纹逐项一致,142 篇帖子被知识条目引用,论坛引用均在范围内,本地链接与差异空白检查通过。新增知识内容和本地 JSON/JSONL 状态的账号密码字面值及 JWT 形式扫描通过。实际 API 采集及本周内容比对已运行;社区回测与代码未执行,自动失败恢复、限流退避仍未经过故障演练。文本及索引同步完成,图片正文访问失败保留为独立缺口。
|
||||
|
||||
## 每周论坛同步:2026-09-25
|
||||
|
||||
实际采集时间为北京时间 09:12:33–09:16:29,随后完成筛读和知识整理。认证返回 201,支持站 SSO 落地 403,Community API 正常。完整取得 19 页、1,859 篇可见帖子,排除 1,101 篇早于年初边界的帖子;本次范围内实际取得 758 篇、10,141 条评论,逐帖分页计数一致,无评论抓取失败。
|
||||
|
||||
相对 9 月 18 日内容基线,新收录 89 篇,其中 72 篇发表于上次采集结束后,17 篇为历史补录。新收录帖带来 9 条评论,217 篇旧帖另有 540 个新可见评论 ID;这 549 条中,67 条发表于上次采集后、482 条创建时间更早,是本轮历史覆盖补齐。旧帖元数据更新时间变化 329 篇,但既有正文指纹无变化,可见帖既有评论没有文本编辑或缺失 ID;没有仅凭更新时间或评论数判断未变。
|
||||
|
||||
此前收录的 41208709211671(傅里叶变换在 Alpha 开发中的应用,2026-06-14)本次列表缺失,直接官方 API 请求也返回 404,原因未定。保留原文快照、引用、索引及其历史 9 条评论,不把 404 解释为已删除。因而当前索引有 759 个 ID:758 个本次可见,1 个历史保留;阅读索引评论数合计 10,150,其中 9 条未在本次重新取得,该帖 comments_complete 记为 False。本次实际取得评论比上次增加 540,等于新可见 549 减去未能重新取得的历史 9,不能将净增量称作新发表评论。
|
||||
|
||||
89 篇新收录帖完成标题及正文主旨筛查,专题目录只作导航,未沿目录扩充 2026 年前帖子;福利、收入展示和未答问题保留索引,不扩写为结论。新收录帖的 9 条评论及旧帖新可见的 540 条评论完成筛读,并针对实质纠错和机制建议精读。新增知识集中在冻结后验证、FFT 互补频带恒等式及修正版局限、PCA 窗口索引、聚类代表不能替代逐项 PC 终验、缺失值语义与 UNKNOWN 状态。社区数值仍为作者自述;本次没有复现回测、安装或执行社区代码,也没有将评论中的配额、评分、时区和阈值升级为官方规则。
|
||||
|
||||
图片帖 43287990820375 仍可取得元数据,正文含 12 张图片;本次抽查其中 1 个官方图片地址仍为 403,没有把抽查写成全部重试,继续保留 metadata_only 和待阅读项。评论指纹仍仅覆盖规范化文字,不检测纯格式、链接目标或图片替换。本次保留上周 670 篇文本快照于本机 previous-2026-09-18/;可见记录验证后推进相应基线,不可访问帖保留旧指纹与重试缺口。此次为可见文本及索引完成、来源与媒体仍有缺口的部分同步,不宣称全量无缺失。
|
||||
|
||||
验证结果:两个 CSV 均有 759 个唯一 ID;758 个本次采集记录与候选指纹、逐评论文本哈希及分页计数一致,另 1 个历史保留记录明确标为当前未完成。154 篇帖子被知识条目引用,引用均属于本次可见范围或明确保留的历史来源。本地链接、差异空白、新增内容及本地 JSON/JSONL 的账号密码字面值和 JWT 形式扫描通过。采集脚本语法检查通过;本轮只验证实际采集与静态内容比对,没有故障注入、策略回测或社区代码执行。
|
||||
|
||||
@@ -29,6 +29,299 @@
|
||||
"No source authentication material is included."
|
||||
],
|
||||
"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",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"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.",
|
||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence."
|
||||
]
|
||||
},
|
||||
{
|
||||
"collected_at": "2026-09-18",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"started_at": "2026-09-18T02:11:39.967012+00:00",
|
||||
"collection_finished_at": "2026-09-18T02:16:36.188053+00:00",
|
||||
"previous_successful_collection_finished_at": "2026-09-11T01:06:33.369594+00:00",
|
||||
"raw_visible_posts": 1771,
|
||||
"raw_list_pages": 18,
|
||||
"excluded_older_posts": 1101,
|
||||
"retained_posts": 670,
|
||||
"retained_comments": 9601,
|
||||
"newly_indexed_posts": 41,
|
||||
"newly_indexed_posts_created_after_previous_collection": 29,
|
||||
"historical_posts_newly_indexed": 12,
|
||||
"comments_on_newly_indexed_posts": 5,
|
||||
"newly_visible_comments_on_existing_posts": 9,
|
||||
"historical_comments_newly_visible": 7,
|
||||
"newly_visible_comments_created_after_previous_collection": 7,
|
||||
"comment_count_net_change": 14,
|
||||
"metadata_changed_existing_posts": 231,
|
||||
"body_changed_existing_post_ids": [
|
||||
"43320471694999",
|
||||
"43030071187479"
|
||||
],
|
||||
"comment_changed_existing_post_ids": [
|
||||
"42302637812503"
|
||||
],
|
||||
"edited_existing_comments": 0,
|
||||
"missing_existing_comment_ids": 0,
|
||||
"failed_comment_records": 0,
|
||||
"missing_previously_indexed_posts": 0,
|
||||
"posts_cited_in_knowledge": 142,
|
||||
"groups": {
|
||||
"portfolio": 50,
|
||||
"ai": 154,
|
||||
"ideas": 30,
|
||||
"lessons": 134,
|
||||
"optimization": 62,
|
||||
"data": 20,
|
||||
"events": 41,
|
||||
"engineering": 75,
|
||||
"guides": 8,
|
||||
"general": 96
|
||||
},
|
||||
"new_post_texts_screened": 40,
|
||||
"metadata_only_post_ids": [
|
||||
"43287990820375"
|
||||
],
|
||||
"new_or_newly_visible_comments_read": 14,
|
||||
"official_articles_checked": [
|
||||
{
|
||||
"id": "41681208900759",
|
||||
"updated_at": "2026-09-13T15:38:21Z",
|
||||
"read_at": "2026-09-18"
|
||||
}
|
||||
],
|
||||
"baseline_state_directory": ".local/forum-sync/",
|
||||
"limits": [
|
||||
"Newly visible records can predate the previous run; cause of historical visibility gaps is unknown.",
|
||||
"One new post is image-only and its body has not been visually reviewed; other images and attachments not fully verified.",
|
||||
"Community backtest claims not reproduced; code findings are static, not live platform tests.",
|
||||
"No simulations, submissions, email sync, public publishing, or credential/session persistence.",
|
||||
"Comment fingerprints cover normalized text only, not link destinations, formatting, or embedded media changes."
|
||||
],
|
||||
"unread_media": [
|
||||
{
|
||||
"post_id": "43287990820375",
|
||||
"image_count": 12,
|
||||
"http_status": 403
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -45,3 +45,11 @@
|
||||
增加同类字段可能只是叠加相关噪声和缺失。帖子记录了同域克隆失败、跨类别组合更好的样本,但没有证明多类别组合必然有效。来源:[字段堆越多alpha越牛?我替你们试过了,真不是这么回事](https://support.worldquantbrain.com/hc/en-us/community/posts/41908323673111)(2026-07-13)。
|
||||
|
||||
保持主字段、方向与覆盖语义后,才讨论删除冗余算子;降低 OpCount 不能靠偷偷改变信号。来源:[【Quant101】OpCount 拯救计划:因子化简](https://support.worldquantbrain.com/hc/en-us/community/posts/41105918774807)(2026-06-10)。详见 [优化流程](../08-alpha-optimization/diagnostic-workflow.md)。
|
||||
|
||||
## 2026-09-25 补充:缺失设置是实验变量
|
||||
|
||||
[按数据类型选择回填](https://support.worldquantbrain.com/hc/en-us/community/posts/43273417454487)(2026-09-05,本轮补录)和 [NanHandling 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43562519639063)(2026-09-17,本轮补录)提醒,事件未发生、状态尚未更新与采集缺失不能用同一填法。NanHandling ON/OFF、算子及字段组合可能改变有效股票数和信号含义;文中观测不是平台所有算子的统一定义,尤其不能推广“其他情况无所谓”。
|
||||
|
||||
[新闻密度帖的新增可见评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43541109631895#community_comment_43582240044695)(评论发表于 2026-09-18)提出:若无新闻日为缺失且均值忽略缺失,得到的是有新闻日的条件均值,并非每个交易日的新闻密度。应先核对实际返回值,再比较零填充、原缺失和有限回填,同时观察覆盖与集中度;不能预先把缺失都当无事件。[预处理讨论评论](https://support.worldquantbrain.com/hc/en-us/community/posts/43527457608087#community_comment_43552980010775)(2026-09-17,本轮补齐)进一步提醒,回填后的 Sharpe 提升可能只是股票构成改变,需并看信号年龄、多空数量、换手及分组暴露。这些是评论者的机制建议,不是已确认因果结果。
|
||||
|
||||
历史频域来源 41208709211671 在本次列表缺失,2026-09-25 直接 API 请求返回 404;保留此前归纳和来源,不据此判断作者删除或内容失效。
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
Osmosis 社区帖称有多个 Region×Delay、每赛道 Alpha 数量与点数要求;具体资格和锁定时区未由本轮完整核实,不将该帖直接升级为当前官方规则。来源:[🌟 玩转 Osmosis:让你的 Alphas 成为最高 2 倍收益放大器!(机制详解与实操指南)](https://support.worldquantbrain.com/hc/en-us/community/posts/43002825233687)(2026-08-26)。
|
||||
|
||||
关于低 PC 与高 Fitness 的取舍,社区有单变量实验支持按质量预算思考,不能只追最低 PC。来源:[关于降Prod Correlation的实践(一)](https://support.worldquantbrain.com/hc/en-us/community/posts/39118594803095)(2026-03-18)。具体权重和阈值需要依据你的池验证,本任务未修改组合、提交 Alpha 或分配点数。
|
||||
|
||||
## 2026-09-25 补充:聚类代表不能替代每个候选的终验
|
||||
|
||||
[本地 PnL 聚类预筛工具](https://support.worldquantbrain.com/hc/en-us/community/posts/43300341649687)(2026-09-07,本轮补录)按候选间相关性合并簇,只查代表项并把 PC 结果传播给成员。减少重复检查可作为调度思路,但 PASS/FAIL 传播没有充分保证。以同一日期样本上中心化、标准化的 A、B、P 为例,柯西不等式给出 |corr(A,P)−corr(B,P)| ≤ √(2(1−corr(A,B)));即使 corr(A,B)=0.9,上界仍约 0.447,远不能保证跨不过提交门槛。这是本库数学边界,不是平台 PC 公式;链式连通聚类还不保证任意两成员都达到聚类阈值。
|
||||
|
||||
[双桶去相关案例](https://support.worldquantbrain.com/hc/en-us/community/posts/43624302417559)(2026-09-20)报告 PC 下降同时 margin 下降,也给出分桶轴与信号重叠导致信号被抹去的反例。因此比较应同时记录成本、覆盖、子区域与生产相关性,不能只凭某项过线判定改善。[Value Factor 讨论](https://support.worldquantbrain.com/hc/en-us/community/posts/43589077012759)(2026-09-18)区分单体质量、自有池增量和平台全池评价;其教学示例不是实测收益或支付公式。上述数值和工具效果均未复现。
|
||||
|
||||
@@ -32,3 +32,9 @@
|
||||
触发条件;完整基线;失败原值;已经试过的修复;对照;新的失败项;适用边界;是否复现;下次何时允许重试。把网络故障、语义错误、检查失败和经济假设被否定分成不同类别。
|
||||
|
||||
本库是当前任务交付物,没有将这些社区经验自动写入全局 Skill、Hook 或硬性提交规则。
|
||||
|
||||
## 2026-09-18 补充:复盘要对齐评价时间
|
||||
|
||||
[OS Comb 六月至七月复盘](https://support.worldquantbrain.com/hc/en-us/community/posts/43438866840343)(2026-09-12)只讨论已更新的六月 3.3+、七月 3.5+,明确八月未刷新,不把后续研究工作解释为此前指标上升的原因。作者把变化归于跨区、多品类和入池质量;这是个人归因,未隔离市场环境、旧池变化和平台统计窗口。可保留的方法是给研究动作、提交入池日和指标评价期分别标记时间,避免倒置因果。
|
||||
|
||||
[负 Combine 到 GM 的复盘上篇](https://support.worldquantbrain.com/hc/en-us/community/posts/43540397996311)及[下篇](https://support.worldquantbrain.com/hc/en-us/community/posts/43540418549527)(均为 2026-09-16)再次强调过提交检查不等于成本后质量、复杂度与多样性需要分开看。晋级与收入结果均为作者自报,不能将成功者经历称为保证可复制的方法,也不能把地区或数据类别数量直接当作有效分散度。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# WorldQuant 研究重启知识库
|
||||
|
||||
首次整理:2026-09-07;论坛最近同步:2026-09-11,Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
||||
首次整理:2026-09-07;论坛最近同步:2026-09-25(有访问缺口),Asia/Shanghai。论坛严格按 **2026-01-01 起的发帖时间**筛选;旧帖即使今年更新、被邮件或合集引用,也不纳入论坛研究来源。面向个人研究,不包含已运行或保证有效的 Alpha。
|
||||
|
||||
9 月 11 日增量:论坛索引现有 **629 篇帖子、9,587 条评论**,新增收录 6 篇(其中 1 篇为 9 月 5 日漏收来源),并补齐原有三篇帖子的 6 条历史评论。新增 ARC2026 官方 FAQ 复核、跨区域重跑经验及论坛公告,见 [平台更新](04-platform/current-updates.md) 和 [工程经验](03-engineering/2026-operating-lessons.md)。首次完整比较基线已建立;旧帖更新时间变化不能直接等同正文编辑,详见 [来源与覆盖](06-sources/README.md)。
|
||||
9 月 25 日增量:本次实际取得 **758 篇可见帖子、10,141 条评论**。新收录 89 篇,其中 72 篇发表于上次采集后、17 篇为历史补录;217 篇旧帖出现新可见评论,未见既有正文或评论文本编辑。更新[时间前推验证](05-research-practice/validation.md)、[FFT/PCA 实现边界](02-ai-mining/python-alpha.md)、[相关性预筛](08-alpha-optimization/portfolio-and-correlation.md)、[数据语义](07-professional-knowledge/data-and-signal-semantics.md)、[工程状态](03-engineering/2026-operating-lessons.md)和[事件信号对照](01-alpha-ideas/data-template-cards.md)。另有 1 篇旧帖返回 404,历史记录仍保留,索引共 759 篇;1 篇图片正文继续待读。详见[来源与覆盖](06-sources/README.md)。
|
||||
|
||||
**先更新研究方向,再恢复回测规模。** 当前最值得投入的是有经济解释的低相关信号、能保留失败证据的 AI 实验闭环,以及组合层的增量价值。不要把“提交数量”“Token 消耗”当作研究进展。
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ export function AlphaDetail({
|
||||
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
|
||||
<Tag>{detail.language ?? "语言未提供"}</Tag>
|
||||
<Tag>{detail.status ?? "状态未提供"}</Tag>
|
||||
{detail.check_type === "PPAC_CANDIDATE" && (
|
||||
<Tag color="yellow" size="small" type="light">
|
||||
候选PPAC
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
@@ -240,6 +245,12 @@ export function AlphaDetail({
|
||||
<Button>在 BRAIN 中打开 ↗</Button>
|
||||
</a>
|
||||
</div>
|
||||
{detail.check_type === "PPAC_CANDIDATE" && (
|
||||
<p className="muted">
|
||||
唯一失败项为 PURE_POWER_POOL_THEME,等待平台开放 PPAC
|
||||
主题后可重新检查提交资格。
|
||||
</p>
|
||||
)}
|
||||
{detail.alpha_type === "SUPER" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
|
||||
@@ -99,6 +99,7 @@ const checkLabels = {
|
||||
PENDING: "待检查",
|
||||
PRE_CHECK: "预检通过",
|
||||
PASS: "检查通过",
|
||||
PPAC_CANDIDATE: "候选PPAC",
|
||||
FAIL_1: "FAIL=1",
|
||||
FAIL_2: "FAIL≥2",
|
||||
};
|
||||
@@ -205,6 +206,7 @@ export function AlphaPage({
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
|
||||
const [submissionBlocked, setSubmissionBlocked] = useState(false);
|
||||
const [ppacCandidate, setPpacCandidate] = useState(false);
|
||||
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
|
||||
const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -274,6 +276,7 @@ export function AlphaPage({
|
||||
management_scope: managementScope,
|
||||
submission,
|
||||
submission_blocked: submissionBlocked || undefined,
|
||||
ppac_candidate: ppacCandidate || undefined,
|
||||
sort,
|
||||
direction,
|
||||
limit: pageSize,
|
||||
@@ -283,6 +286,7 @@ export function AlphaPage({
|
||||
filters,
|
||||
submission,
|
||||
submissionBlocked,
|
||||
ppacCandidate,
|
||||
sort,
|
||||
direction,
|
||||
pageSize,
|
||||
@@ -320,6 +324,7 @@ export function AlphaPage({
|
||||
offset: _offset,
|
||||
submission: nextSubmission,
|
||||
submission_blocked: nextBlocked,
|
||||
ppac_candidate: nextPpacCandidate,
|
||||
...values
|
||||
} = action.filters;
|
||||
const next = Object.fromEntries(
|
||||
@@ -334,6 +339,9 @@ export function AlphaPage({
|
||||
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
|
||||
setSubmission(nextSubmission);
|
||||
setSubmissionBlocked(nextBlocked === true || nextBlocked === "true");
|
||||
setPpacCandidate(
|
||||
nextPpacCandidate === true || nextPpacCandidate === "true",
|
||||
);
|
||||
if (nextSort) setSort(String(nextSort));
|
||||
if (nextDirection) setDirection(String(nextDirection));
|
||||
}
|
||||
@@ -367,7 +375,7 @@ export function AlphaPage({
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
selectionVersions.current = {};
|
||||
}, [filters, submission, submissionBlocked, managementScope]);
|
||||
}, [filters, submission, submissionBlocked, ppacCandidate, managementScope]);
|
||||
useEffect(() => {
|
||||
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
||||
}, [query]);
|
||||
@@ -394,6 +402,7 @@ export function AlphaPage({
|
||||
setDirection("desc");
|
||||
setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED");
|
||||
setSubmissionBlocked(value === "SUBMISSION_BLOCKED");
|
||||
setPpacCandidate(value === "PPAC_CANDIDATE");
|
||||
setDraft({});
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
@@ -582,8 +591,12 @@ export function AlphaPage({
|
||||
width: 125,
|
||||
render: (_, row) => (
|
||||
<Tag
|
||||
size="small"
|
||||
type="light"
|
||||
color={
|
||||
row!.check_type?.startsWith("FAIL")
|
||||
row!.check_type === "PPAC_CANDIDATE"
|
||||
? "yellow"
|
||||
: row!.check_type?.startsWith("FAIL")
|
||||
? "red"
|
||||
: row!.check_type === "PASS"
|
||||
? "green"
|
||||
@@ -1010,6 +1023,7 @@ export function AlphaPage({
|
||||
managementScope={managementScope}
|
||||
submission={submission}
|
||||
submissionBlocked={submissionBlocked}
|
||||
ppacCandidate={ppacCandidate}
|
||||
selectedId={selectedViewId}
|
||||
onSelect={setSelectedViewId}
|
||||
onSubmissionChange={changeSubmission}
|
||||
@@ -1019,6 +1033,7 @@ export function AlphaPage({
|
||||
...filters,
|
||||
submission,
|
||||
submission_blocked: submissionBlocked || undefined,
|
||||
ppac_candidate: ppacCandidate || undefined,
|
||||
sort,
|
||||
direction,
|
||||
limit: pageSize,
|
||||
@@ -1029,6 +1044,7 @@ export function AlphaPage({
|
||||
const {
|
||||
submission: savedSubmission,
|
||||
submission_blocked: savedBlocked,
|
||||
ppac_candidate: savedPpacCandidate,
|
||||
sort: savedSort,
|
||||
direction: savedDirection,
|
||||
limit,
|
||||
@@ -1050,6 +1066,9 @@ export function AlphaPage({
|
||||
setSubmissionBlocked(
|
||||
savedBlocked === true || savedBlocked === "true",
|
||||
);
|
||||
setPpacCandidate(
|
||||
savedPpacCandidate === true || savedPpacCandidate === "true",
|
||||
);
|
||||
setSort(String(savedSort || "date_created"));
|
||||
setDirection(String(savedDirection || "desc"));
|
||||
if (typeof limit === "number") setPageSize(limit);
|
||||
@@ -1336,12 +1355,16 @@ export function AlphaPage({
|
||||
<Empty
|
||||
className="alpha-empty"
|
||||
title={
|
||||
activeFilterCount
|
||||
ppacCandidate
|
||||
? "没有符合条件的候选PPAC"
|
||||
: activeFilterCount
|
||||
? "没有符合条件的 Alpha"
|
||||
: "从第一个 Alpha 开始"
|
||||
}
|
||||
description={
|
||||
activeFilterCount
|
||||
ppacCandidate
|
||||
? "仅收录未提交且唯一失败项为 PURE_POWER_POOL_THEME 的 Alpha,等待平台开放 PPAC 主题。"
|
||||
: activeFilterCount
|
||||
? "调整筛选条件,探索更多信号。"
|
||||
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
||||
}
|
||||
@@ -1351,7 +1374,9 @@ export function AlphaPage({
|
||||
<footer className="table-pagination" aria-label="Alpha 分页">
|
||||
<div className="alpha-table-meta">
|
||||
<span className="muted">
|
||||
{submissionBlocked
|
||||
{ppacCandidate
|
||||
? "候选PPAC"
|
||||
: submissionBlocked
|
||||
? "提交受阻"
|
||||
: submission === "UNSUBMITTED"
|
||||
? "待提交"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "@douyinfe/semi-ui-19";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import type { ResearchCandidate } from "./workspaceTypes";
|
||||
|
||||
/** Selection spans pages; eligibility is supplied by the producing workflow. */
|
||||
export function CandidateTable({
|
||||
candidates,
|
||||
selected,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
requireValidation = false,
|
||||
}: {
|
||||
candidates: ResearchCandidate[];
|
||||
selected: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
requireValidation?: boolean;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
return (
|
||||
<>
|
||||
<WorkspaceTable<ResearchCandidate>
|
||||
dataSource={candidates.slice((page - 1) * pageSize, page * pageSize)}
|
||||
className="research-candidate-table"
|
||||
rowKey="client_item_id"
|
||||
scroll={{ x: 1120, y: 400 }}
|
||||
empty="暂无候选表达式。"
|
||||
rowSelection={{
|
||||
width: 48,
|
||||
disabled,
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => onSelectionChange((keys ?? []).map(String)),
|
||||
getCheckboxProps: (candidate) => ({
|
||||
disabled:
|
||||
disabled ||
|
||||
(requireValidation && candidate.validation?.status !== "valid"),
|
||||
"aria-label": `选择候选 ${candidate.client_item_id}`,
|
||||
title: requireValidation
|
||||
? [
|
||||
...(candidate.validation?.syntax ?? []),
|
||||
...(candidate.validation?.types ?? []),
|
||||
...(candidate.validation?.availability ?? []),
|
||||
].join(";")
|
||||
: undefined,
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "表达式",
|
||||
dataIndex: "expression",
|
||||
width: 420,
|
||||
render: (value) => <code title={value}>{value}</code>,
|
||||
},
|
||||
...(
|
||||
[
|
||||
["neutralization", "Neutralization", 170],
|
||||
["decay", "Decay", 90],
|
||||
["truncation", "Truncation", 120],
|
||||
["maxTrade", "Max Trade", 120],
|
||||
["nanHandling", "NaN Handling", 150],
|
||||
] as const
|
||||
).map(([key, title, width]) => ({
|
||||
key,
|
||||
title,
|
||||
width,
|
||||
render: (_: unknown, candidate: ResearchCandidate) =>
|
||||
candidate.settings[key],
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="table-pagination workspace-table-footer">
|
||||
<span>
|
||||
共 {candidates.length} 个候选 · 已选 {selected.length} 个
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
total={candidates.length}
|
||||
showSizeChanger
|
||||
pageSizeOpts={[25, 50, 100]}
|
||||
preventPageChangeOnPageSizeChange
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Table,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post, formatTime } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
import { EvaluationPanel } from "./EvaluationPanel";
|
||||
import { LineagePanel } from "./LineagePanel";
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { validationLabel } from "./workspaceTypes";
|
||||
import { CandidateTable } from "./CandidateTable";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
|
||||
export function ExperimentView({
|
||||
@@ -33,7 +26,7 @@ export function ExperimentView({
|
||||
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const valid = experiment.candidates.filter(
|
||||
(c) => c.validation.status === "valid",
|
||||
(c) => c.validation?.status === "valid",
|
||||
);
|
||||
async function preview() {
|
||||
setBusy(true);
|
||||
@@ -72,7 +65,10 @@ export function ExperimentView({
|
||||
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>研究描述</summary>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
</details>
|
||||
{experiment.archived ? (
|
||||
<Banner
|
||||
type="info"
|
||||
@@ -92,175 +88,12 @@ export function ExperimentView({
|
||||
type="info"
|
||||
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
|
||||
/>
|
||||
<div className="research-table-scroll">
|
||||
<Table<Experiment["candidates"][number]>
|
||||
className="research-table"
|
||||
dataSource={experiment.candidates}
|
||||
rowKey="client_item_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
style={{ minWidth: 840 }}
|
||||
empty="暂无候选表达式。"
|
||||
columns={[
|
||||
{
|
||||
title: "选择",
|
||||
key: "selection",
|
||||
width: 64,
|
||||
render: (_, candidate) => (
|
||||
<Checkbox
|
||||
aria-label={`选择候选 ${candidate.client_item_id}`}
|
||||
disabled={candidate.validation.status !== "valid"}
|
||||
checked={selected.includes(candidate.client_item_id)}
|
||||
onChange={(event) =>
|
||||
setSelected((old) =>
|
||||
event.target.checked
|
||||
? [...old, candidate.client_item_id]
|
||||
: old.filter((id) => id !== candidate.client_item_id),
|
||||
)
|
||||
}
|
||||
<CandidateTable
|
||||
candidates={experiment.candidates}
|
||||
selected={selected}
|
||||
onSelectionChange={setSelected}
|
||||
requireValidation
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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">
|
||||
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
|
||||
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
|
||||
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Tabs,
|
||||
TextArea,
|
||||
Toast,
|
||||
Typography,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import { ResearchInput } from "../ai/ResearchInput";
|
||||
import { IconAIEditLevel3 } from "@douyinfe/semi-icons";
|
||||
import { api, post, formatTime } from "../api";
|
||||
@@ -30,17 +33,11 @@ import {
|
||||
} from "./workspaceTypes";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import { ExperimentView } from "./ExperimentView";
|
||||
import { TemplateCandidateSet } from "./TemplateCandidateSet";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
import { ComparisonPanel } from "./ComparisonPanel";
|
||||
import "./workspace.css";
|
||||
|
||||
type ImportResult = {
|
||||
templates: Template[];
|
||||
digest: string;
|
||||
conflicts: unknown[];
|
||||
errors: unknown[];
|
||||
differences?: unknown[];
|
||||
};
|
||||
export function ResearchWorkspace({
|
||||
page,
|
||||
active,
|
||||
@@ -54,6 +51,7 @@ export function ResearchWorkspace({
|
||||
onAction: (action: UIAction) => void;
|
||||
onContext: (context: PageContext) => void;
|
||||
}) {
|
||||
const [detailTab, setDetailTab] = useState("editor");
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailMode, setDetailMode] = useState<"editor" | "experiment">(
|
||||
"editor",
|
||||
@@ -86,11 +84,27 @@ export function ResearchWorkspace({
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [historyTotal, setHistoryTotal] = useState(0);
|
||||
const refreshSequence = useRef(0);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const CandidateView =
|
||||
experiment?.kind === "template" ? TemplateCandidateSet : ExperimentView;
|
||||
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
|
||||
const dirty =
|
||||
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
|
||||
const generationKey = JSON.stringify([
|
||||
asset?.id,
|
||||
asset?.version,
|
||||
template,
|
||||
inputIds,
|
||||
inputs,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
]);
|
||||
const currentGeneration = useRef(generationKey);
|
||||
currentGeneration.current = generationKey;
|
||||
useEffect(() => {
|
||||
if (page === "templates" && detailMode === "editor") setExperiment(null);
|
||||
}, [page, template, inputIds, inputs, settings, mode, limit, seed]);
|
||||
async function task(label: string, action: () => Promise<void>) {
|
||||
setBusy(label);
|
||||
setError("");
|
||||
@@ -104,13 +118,12 @@ export function ResearchWorkspace({
|
||||
}
|
||||
async function refresh() {
|
||||
const sequence = ++refreshSequence.current;
|
||||
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
|
||||
const [nextAssets, nextHistory] = await Promise.all([
|
||||
api<{ items: Asset[]; total: number }>(
|
||||
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
|
||||
),
|
||||
Promise.resolve({ items: inputs }),
|
||||
api<{ items: typeof history; total: number }>(
|
||||
`/research/experiments?limit=25&offset=${historyPage * 25}`,
|
||||
`/research/experiments?kind=${page === "templates" ? "template" : "variant"}&limit=25&offset=${historyPage * 25}`,
|
||||
),
|
||||
]);
|
||||
if (sequence !== refreshSequence.current) return;
|
||||
@@ -123,7 +136,6 @@ export function ResearchWorkspace({
|
||||
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
|
||||
);
|
||||
setAssets(nextAssets.items);
|
||||
setInputs(nextInputs.items);
|
||||
setHistory(nextHistory.items);
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -150,6 +162,7 @@ export function ResearchWorkspace({
|
||||
["open_experiment", "open_template", "open_variant"].includes(action.type)
|
||||
) {
|
||||
setDetailOpen(true);
|
||||
setDetailTab("editor");
|
||||
setDetailMode(
|
||||
action.type === "open_experiment" ? "experiment" : "editor",
|
||||
);
|
||||
@@ -165,32 +178,34 @@ export function ResearchWorkspace({
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
const feature = next.provenance?.feature;
|
||||
if (feature) {
|
||||
setInputIds(feature.content.input_ids);
|
||||
setHypothesis(feature.content.hypothesis);
|
||||
const fixed = feature.provenance?.inputs || [];
|
||||
setInputs((old) => [
|
||||
...old,
|
||||
...fixed.filter((i) => !old.some((o) => o.id === i.id)),
|
||||
]);
|
||||
const first = fixed[0];
|
||||
if (first)
|
||||
setSettings((old) => ({
|
||||
...old,
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}));
|
||||
}
|
||||
openTemplate(next);
|
||||
});
|
||||
if (action.type === "open_variant") {
|
||||
setParent(action.alpha_id);
|
||||
setMethod("structure");
|
||||
}
|
||||
}, [active, action]);
|
||||
function openTemplate(next: Asset) {
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
setExperiment(null);
|
||||
const feature = next.provenance?.feature;
|
||||
const fixed = feature?.provenance?.inputs || [];
|
||||
setInputIds(feature?.content.input_ids || []);
|
||||
setInputs(fixed);
|
||||
setHypothesis(feature?.content.hypothesis || "");
|
||||
const first = fixed[0];
|
||||
setSettings({
|
||||
...initialSettings,
|
||||
...(first
|
||||
? {
|
||||
region: first.scope.region,
|
||||
universe: first.scope.universe,
|
||||
delay: first.scope.delay,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
async function save() {
|
||||
const next = asset
|
||||
? await api<Asset>(`/research/assets/${asset.id}`, {
|
||||
@@ -253,14 +268,21 @@ export function ResearchWorkspace({
|
||||
asset_id: asset!.id,
|
||||
version: asset!.version,
|
||||
...researchSelection(inputIds, inputs),
|
||||
hypothesis,
|
||||
hypothesis:
|
||||
page === "templates"
|
||||
? template.description.trim() || `使用模板:${template.name}`
|
||||
: hypothesis,
|
||||
settings,
|
||||
mode,
|
||||
limit,
|
||||
seed,
|
||||
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
});
|
||||
if (page === "templates" && currentGeneration.current !== generationKey) {
|
||||
throw new Error("回测准备已改变,请按当前配置重新生成候选集合");
|
||||
}
|
||||
setExperiment(next);
|
||||
setDetailTab("prepare");
|
||||
await refresh();
|
||||
}
|
||||
const combination = Object.values(template.variables)
|
||||
@@ -308,6 +330,11 @@ export function ResearchWorkspace({
|
||||
setAsset(null);
|
||||
setTemplate(blankTemplate());
|
||||
setExperiment(null);
|
||||
setInputIds([]);
|
||||
setInputs([]);
|
||||
setHypothesis("");
|
||||
setSettings(initialSettings);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
@@ -354,20 +381,20 @@ export function ResearchWorkspace({
|
||||
}
|
||||
>
|
||||
{listMode === "assets" ? (
|
||||
<>
|
||||
<Table<Asset>
|
||||
className="research-table"
|
||||
<div className="research-template-table-container">
|
||||
<WorkspaceTable<Asset>
|
||||
key={JSON.stringify([search, assetPage])}
|
||||
fill
|
||||
dataSource={assets}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
sticky
|
||||
style={{ minWidth: 720 }}
|
||||
empty="还没有模板。新建或导入一个模板开始研究。"
|
||||
scroll={{ x: 800, y: "100%" }}
|
||||
empty="还没有模板。新建一个模板开始研究。"
|
||||
columns={[
|
||||
{
|
||||
title: "模板名称",
|
||||
key: "name",
|
||||
width: 280,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_, item) => (
|
||||
<Button
|
||||
theme="borderless"
|
||||
@@ -376,15 +403,20 @@ export function ResearchWorkspace({
|
||||
const next = await api<Asset>(
|
||||
`/research/assets/${item.id}`,
|
||||
);
|
||||
setAsset(next);
|
||||
setTemplate(next.content);
|
||||
setExperiment(null);
|
||||
openTemplate(next);
|
||||
setDetailTab("editor");
|
||||
setDetailMode("editor");
|
||||
setDetailOpen(true);
|
||||
})
|
||||
}
|
||||
>
|
||||
<Typography.Text
|
||||
size="inherit"
|
||||
style={{ width: "100%", color: "inherit" }}
|
||||
ellipsis={{ showTooltip: true }}
|
||||
>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
@@ -397,8 +429,17 @@ export function ResearchWorkspace({
|
||||
{
|
||||
title: "表达式",
|
||||
key: "expression",
|
||||
width: 260,
|
||||
render: (_, item) => <code>{item.content.expression}</code>,
|
||||
width: undefined,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (_, item) => (
|
||||
<Typography.Text
|
||||
className="research-template-expression"
|
||||
style={{ width: "100%" }}
|
||||
ellipsis={{ showTooltip: true }}
|
||||
>
|
||||
{item.content.expression}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
@@ -412,6 +453,7 @@ export function ResearchWorkspace({
|
||||
key: "actions",
|
||||
width: 80,
|
||||
render: (_, item) => (
|
||||
<div className="workspace-cell-actions">
|
||||
<DeleteResearchButton
|
||||
name={item.name}
|
||||
label="模板"
|
||||
@@ -420,11 +462,12 @@ export function ResearchWorkspace({
|
||||
task("删除模板", () => removeAsset(item))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table<(typeof history)[number]>
|
||||
@@ -504,7 +547,7 @@ export function ResearchWorkspace({
|
||||
{error && <Banner type="danger" description={error} />}
|
||||
{detailMode === "experiment" ? (
|
||||
experiment && (
|
||||
<ExperimentView
|
||||
<CandidateView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
@@ -519,9 +562,23 @@ export function ResearchWorkspace({
|
||||
{asset?.archived && (
|
||||
<Banner
|
||||
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">
|
||||
<h3>研究输入与假设</h3>
|
||||
{page === "variants" && (
|
||||
@@ -639,11 +696,15 @@ export function ResearchWorkspace({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{page !== "variants" || method === "structure" ? (
|
||||
<>
|
||||
<div hidden={page === "templates" && detailTab !== "editor"}>
|
||||
<section className="research-card">
|
||||
<div className="research-section-heading">
|
||||
<h3>
|
||||
编辑与展开{asset && ` · v${asset.version}`}
|
||||
模板信息{asset && ` · v${asset.version}`}
|
||||
{dirty && " · 未保存"}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -651,7 +712,26 @@ export function ResearchWorkspace({
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
inputs={selectedInputs}
|
||||
definitionsOnly={page === "templates"}
|
||||
/>
|
||||
{page === "templates" ? (
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!!busy || asset?.archived || !dirty}
|
||||
onClick={() => void task("保存模板", save)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!busy || !asset || asset.archived}
|
||||
onClick={() => void task("新增版本", save)}
|
||||
>
|
||||
新增版本
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{asset && (
|
||||
<div className="research-toolbar">
|
||||
<span>版本记录</span>
|
||||
@@ -664,7 +744,10 @@ export function ResearchWorkspace({
|
||||
assets.find((item) => item.id === asset.id)
|
||||
?.version || asset.version,
|
||||
},
|
||||
(_, i) => ({ value: i + 1, label: `v${i + 1}` }),
|
||||
(_, i) => ({
|
||||
value: i + 1,
|
||||
label: `v${i + 1}`,
|
||||
}),
|
||||
)}
|
||||
onChange={(version) =>
|
||||
void task("读取历史版本", async () => {
|
||||
@@ -708,6 +791,47 @@ export function ResearchWorkspace({
|
||||
)}
|
||||
<span>组合规模:{combination}</span>
|
||||
</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">
|
||||
<label>
|
||||
展开方式
|
||||
@@ -741,6 +865,9 @@ export function ResearchWorkspace({
|
||||
</label>
|
||||
</div>
|
||||
<SimulationSettingsEditor
|
||||
validationMode={
|
||||
page === "templates" ? "combination" : "platform"
|
||||
}
|
||||
value={settings}
|
||||
onChange={setSettings}
|
||||
{...options}
|
||||
@@ -754,15 +881,31 @@ export function ResearchWorkspace({
|
||||
disabled={
|
||||
dirty ||
|
||||
!inputIds.length ||
|
||||
!hypothesis ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!!busy ||
|
||||
!settingsValid
|
||||
}
|
||||
onClick={() => void task("生成候选", expand)}
|
||||
>
|
||||
保存候选研究记录
|
||||
生成候选集合
|
||||
</Button>
|
||||
{(!inputIds.length ||
|
||||
(page === "variants" && !hypothesis) ||
|
||||
!settingsValid) && (
|
||||
<p className="research-hint">
|
||||
待完成:
|
||||
{[
|
||||
!inputIds.length && "选择固定研究输入",
|
||||
page === "variants" && !hypothesis && "填写研究假设",
|
||||
!settingsValid && "检查模拟设置",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("、")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<section className="research-card">
|
||||
<h3>目标范围检查</h3>
|
||||
@@ -789,8 +932,9 @@ export function ResearchWorkspace({
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
<div hidden={page === "templates" && detailTab !== "prepare"}>
|
||||
{experiment && (
|
||||
<ExperimentView
|
||||
<CandidateView
|
||||
key={experiment.id}
|
||||
experiment={experiment}
|
||||
onAction={onAction}
|
||||
@@ -800,65 +944,7 @@ export function ResearchWorkspace({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{page === "templates" && (
|
||||
<details className="research-card">
|
||||
<summary>导入旧模板</summary>
|
||||
<p>
|
||||
粘贴 cnhk 模板 JSON
|
||||
或模板数组。先预览转换结果,同名模板不会自动覆盖。
|
||||
</p>
|
||||
<TextArea
|
||||
aria-label="导入模板 JSON"
|
||||
value={importText}
|
||||
onChange={(text) => {
|
||||
setImportText(text);
|
||||
setImportResult(null);
|
||||
}}
|
||||
autosize={{ minRows: 4, maxRows: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!!busy || !importText}
|
||||
onClick={() =>
|
||||
void task("预览导入", async () => {
|
||||
const data = JSON.parse(importText);
|
||||
setImportResult(
|
||||
await post("/research/templates/import-preview", {
|
||||
templates: Array.isArray(data) ? data : [data],
|
||||
}),
|
||||
);
|
||||
})
|
||||
}
|
||||
>
|
||||
预览导入差异
|
||||
</Button>
|
||||
{importResult && (
|
||||
<>
|
||||
<pre>{JSON.stringify(importResult, null, 2)}</pre>
|
||||
<Button
|
||||
disabled={
|
||||
!!busy ||
|
||||
!!importResult.errors?.length ||
|
||||
!!importResult.conflicts?.length
|
||||
}
|
||||
onClick={() =>
|
||||
void task("导入模板", async () => {
|
||||
await post("/research/templates/import", {
|
||||
templates: importResult.templates,
|
||||
digest: importResult.digest,
|
||||
});
|
||||
setImportResult(null);
|
||||
setImportText("");
|
||||
await refresh();
|
||||
Toast.success("导入完成");
|
||||
})
|
||||
}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{page === "variants" && (
|
||||
<ComparisonPanel key={parent} baseline={parent} />
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,7 @@ export function SavedViews({
|
||||
columns,
|
||||
submission,
|
||||
submissionBlocked,
|
||||
ppacCandidate,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onSubmissionChange,
|
||||
@@ -61,6 +62,7 @@ export function SavedViews({
|
||||
columns: string[];
|
||||
submission: Submission;
|
||||
submissionBlocked: boolean;
|
||||
ppacCandidate: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onSubmissionChange: (submission: string) => void;
|
||||
@@ -75,6 +77,11 @@ export function SavedViews({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const current = items.find((item) => item.id === selectedId);
|
||||
const fixedTab = ppacCandidate
|
||||
? "PPAC_CANDIDATE"
|
||||
: submissionBlocked
|
||||
? "SUBMISSION_BLOCKED"
|
||||
: submission;
|
||||
const modified =
|
||||
current &&
|
||||
(filterSignature(filters) !== filterSignature(current.content.filters) ||
|
||||
@@ -165,10 +172,7 @@ export function SavedViews({
|
||||
setItems((previous) =>
|
||||
previous.filter((item) => item.id !== deleting.id),
|
||||
);
|
||||
if (selectedId === deleting.id)
|
||||
onSubmissionChange(
|
||||
submissionBlocked ? "SUBMISSION_BLOCKED" : submission,
|
||||
);
|
||||
if (selectedId === deleting.id) onSubmissionChange(fixedTab);
|
||||
setDeleting(null);
|
||||
Toast.success("视图已删除");
|
||||
} catch (error) {
|
||||
@@ -185,10 +189,7 @@ export function SavedViews({
|
||||
type="card"
|
||||
collapsible="auto"
|
||||
tabPaneMotion={false}
|
||||
activeKey={
|
||||
selectedId ??
|
||||
(submissionBlocked ? "SUBMISSION_BLOCKED" : submission)
|
||||
}
|
||||
activeKey={selectedId ?? fixedTab}
|
||||
onChange={(key) => {
|
||||
const view = items.find((item) => item.id === key);
|
||||
if (view) {
|
||||
@@ -212,6 +213,11 @@ export function SavedViews({
|
||||
tab: "提交受阻",
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
itemKey: "PPAC_CANDIDATE",
|
||||
tab: "候选PPAC",
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
},
|
||||
...items.map((view) => ({
|
||||
itemKey: view.id,
|
||||
icon: <IconList aria-hidden="true" />,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import { CandidateTable } from "./CandidateTable";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
|
||||
/** The generated collection is the confirmation surface for a template backtest. */
|
||||
export function TemplateCandidateSet({
|
||||
experiment,
|
||||
onAction,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
experiment: Experiment;
|
||||
onAction: (action: UIAction) => void;
|
||||
onDelete?: () => Promise<void>;
|
||||
deleting?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(() =>
|
||||
experiment.candidates.map((c) => c.client_item_id),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [runs, setRuns] = useState(experiment.backtest_run_ids);
|
||||
const attempt = useRef<{ selection: string; key: string } | null>(null);
|
||||
const pending = useRef(false);
|
||||
async function backtest() {
|
||||
if (pending.current || !selected.length) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
// Retain the same request key after network failure: retry must not create a second run.
|
||||
const selection = JSON.stringify([...selected].sort());
|
||||
if (attempt.current?.selection !== selection) {
|
||||
attempt.current = { selection, key: crypto.randomUUID() };
|
||||
}
|
||||
try {
|
||||
const result = await post<{ backtest_run_id: string }>(
|
||||
`/research/experiments/${experiment.id}/backtest`,
|
||||
{
|
||||
candidate_ids: selected,
|
||||
idempotency_key: attempt.current.key,
|
||||
},
|
||||
);
|
||||
setRuns((old) => [...new Set([...old, result.backtest_run_id])]);
|
||||
onAction({
|
||||
type: "open_backtest",
|
||||
run_id: result.backtest_run_id,
|
||||
nonce: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
Toast.error((error as Error).message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="research-card" aria-label="回测候选集合">
|
||||
<h3>回测候选集合</h3>
|
||||
<details>
|
||||
<summary>研究描述</summary>
|
||||
<p>{experiment.hypothesis}</p>
|
||||
</details>
|
||||
{experiment.archived && (
|
||||
<Banner type="info" description="此候选集合已删除,保留历史关联。" />
|
||||
)}
|
||||
<p>确认表达式和参数后,点击“回测”将所选候选交给回测研究批量执行。</p>
|
||||
<CandidateTable
|
||||
candidates={experiment.candidates}
|
||||
selected={selected}
|
||||
onSelectionChange={setSelected}
|
||||
disabled={busy || deleting || experiment.archived}
|
||||
/>
|
||||
<div className="research-toolbar">
|
||||
<Button
|
||||
theme="solid"
|
||||
disabled={!selected.length || deleting || experiment.archived}
|
||||
loading={busy}
|
||||
onClick={() => void backtest()}
|
||||
>
|
||||
回测({selected.length})
|
||||
</Button>
|
||||
{onDelete && !experiment.archived && (
|
||||
<DeleteResearchButton
|
||||
name={experiment.name}
|
||||
label="候选集合"
|
||||
disabled={busy || deleting}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
)}
|
||||
{runs.map((id, index) => (
|
||||
<Button
|
||||
key={id}
|
||||
theme="borderless"
|
||||
onClick={() =>
|
||||
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
|
||||
}
|
||||
>
|
||||
关联回测 {index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,12 @@ export function TemplateEditor({
|
||||
value,
|
||||
onChange,
|
||||
inputs,
|
||||
definitionsOnly = false,
|
||||
}: {
|
||||
value: Template;
|
||||
onChange: (value: Template) => void;
|
||||
inputs: InputSnapshot[];
|
||||
definitionsOnly?: boolean;
|
||||
}) {
|
||||
function expression(text: string) {
|
||||
const names = [
|
||||
@@ -33,7 +35,9 @@ export function TemplateEditor({
|
||||
variables: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
value.variables[name] || {
|
||||
Object.hasOwn(value.variables, name)
|
||||
? value.variables[name]
|
||||
: {
|
||||
kind: "field",
|
||||
field_type: "MATRIX",
|
||||
values: [],
|
||||
@@ -95,7 +99,56 @@ export function TemplateEditor({
|
||||
使用 {"{name}"}{" "}
|
||||
声明变量;重复占位符共享同一个取值。组合片段只复用表达式,不修改平台算子定义。
|
||||
</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}>
|
||||
<strong>{`{${name}}`}</strong>
|
||||
<ResearchSelect
|
||||
@@ -145,7 +198,8 @@ export function TemplateEditor({
|
||||
values: text
|
||||
.split("\n")
|
||||
.map((v) =>
|
||||
["integer", "number"].includes(item.kind) && v.trim() !== ""
|
||||
["integer", "number"].includes(item.kind) &&
|
||||
v.trim() !== ""
|
||||
? Number(v)
|
||||
: v,
|
||||
),
|
||||
@@ -174,7 +228,8 @@ export function TemplateEditor({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@
|
||||
.research-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.research-template-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.research-template-expression {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.research-table .semi-table-row-cell {
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -362,3 +374,32 @@
|
||||
flex-shrink: 0;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.research-variable-definition {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.5fr) minmax(140px, 1fr) minmax(
|
||||
200px,
|
||||
2fr
|
||||
);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
}
|
||||
.research-variable-definition > strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.research-variable-definition {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
padding-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.research-card .research-candidate-table .semi-checkbox {
|
||||
margin: 0;
|
||||
flex-direction: row;
|
||||
}
|
||||
.research-candidate-table .workspace-cell code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Variable = {
|
||||
| "string"
|
||||
| "fragment";
|
||||
values: (string | number)[];
|
||||
description?: string;
|
||||
field_type?: "MATRIX" | "VECTOR" | "GROUP" | null;
|
||||
};
|
||||
export type Template = {
|
||||
@@ -18,7 +19,6 @@ export type Template = {
|
||||
expression: string;
|
||||
variables: Record<string, Variable>;
|
||||
category: "template" | "fragment";
|
||||
scope?: InputSnapshot["scope"] | null;
|
||||
};
|
||||
export type Asset = {
|
||||
id: string;
|
||||
@@ -55,7 +55,7 @@ export type InputSnapshot = {
|
||||
created_at: string;
|
||||
};
|
||||
export type ResearchCandidate = Candidate & {
|
||||
validation: {
|
||||
validation?: {
|
||||
status: string;
|
||||
syntax: string[];
|
||||
types: string[];
|
||||
|
||||
@@ -28,7 +28,9 @@ export function SimulationSettingsEditor({
|
||||
fixedScopes,
|
||||
disabled = false,
|
||||
onValidityChange,
|
||||
validationMode = "platform",
|
||||
}: {
|
||||
validationMode?: "platform" | "combination";
|
||||
alphaType?: "REGULAR" | "SUPER";
|
||||
value: SimulationSettings;
|
||||
onChange: (value: SimulationSettings) => void;
|
||||
@@ -43,10 +45,16 @@ export function SimulationSettingsEditor({
|
||||
}) {
|
||||
const scope = settingsScope(value);
|
||||
const row = rows.find((r) => sameScope(r, scope));
|
||||
const errors = settingsErrors(value, rows, fixedScopes);
|
||||
const combinationOnly = validationMode === "combination";
|
||||
const errors = combinationOnly
|
||||
? !fixedScopes?.length || fixedScopes.some((s) => !sameScope(s, scope))
|
||||
? ["所选数据准备必须具有相同组合,并与回测组合一致"]
|
||||
: []
|
||||
: settingsErrors(value, rows, fixedScopes);
|
||||
const [scopeReady, setScopeReady] = useState(false);
|
||||
const valid =
|
||||
!loading &&
|
||||
const valid = combinationOnly
|
||||
? !errors.length
|
||||
: !loading &&
|
||||
!error &&
|
||||
!errors.length &&
|
||||
(fixedScopes !== undefined || scopeReady);
|
||||
@@ -54,7 +62,19 @@ export function SimulationSettingsEditor({
|
||||
onValidityChange?.(valid);
|
||||
}, [valid, onValidityChange]);
|
||||
function field(key: string) {
|
||||
const options = fieldOptions(row, key);
|
||||
const options = combinationOnly
|
||||
? key === "neutralization"
|
||||
? {
|
||||
choices: [
|
||||
...new Set([
|
||||
value.neutralization,
|
||||
...(row?.neutralizations ??
|
||||
rows.flatMap((r) => r.neutralizations)),
|
||||
]),
|
||||
],
|
||||
}
|
||||
: fieldOptions(undefined, key)
|
||||
: fieldOptions(row, key);
|
||||
const current =
|
||||
value[key as keyof SimulationSettings] ??
|
||||
(key === "maxPosition" ? "OFF" : undefined);
|
||||
@@ -71,7 +91,7 @@ export function SimulationSettingsEditor({
|
||||
value={typeof current === "boolean" ? String(current) : current}
|
||||
disabled={
|
||||
disabled ||
|
||||
loading ||
|
||||
(loading && !combinationOnly) ||
|
||||
!options.choices.length ||
|
||||
(options.choices.length === 1 && !message)
|
||||
}
|
||||
@@ -97,7 +117,7 @@ export function SimulationSettingsEditor({
|
||||
? current
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || (loading && !combinationOnly)}
|
||||
min={options.minimum}
|
||||
max={options.maximum}
|
||||
precision={
|
||||
@@ -129,16 +149,30 @@ export function SimulationSettingsEditor({
|
||||
选项更新时间:{formatTime(fetchedAt ?? null)}
|
||||
</small>
|
||||
{error && (
|
||||
<Banner type="warning" description={`${error};请重试同步合法设置`} />
|
||||
<Banner
|
||||
type="warning"
|
||||
description={
|
||||
combinationOnly
|
||||
? "参数选项暂不可用,可使用当前参数继续生成候选。"
|
||||
: `${error};请重试同步合法设置`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!loading && !rows.length && !error && (
|
||||
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
combinationOnly
|
||||
? "可同步参数选项辅助选择,当前参数仍可用于生成候选。"
|
||||
: "尚无参数选项,请先同步合法设置。"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ScopePicker
|
||||
value={scope}
|
||||
options={asScopeOptions(rows)}
|
||||
readOnly={fixedScopes !== undefined}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || (loading && !combinationOnly)}
|
||||
onValidityChange={setScopeReady}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
|
||||
@@ -58,7 +58,13 @@ export type Alpha = {
|
||||
date_created: string | null;
|
||||
date_submitted: string | null;
|
||||
synced_at: string;
|
||||
check_type: "PENDING" | "PRE_CHECK" | "PASS" | "FAIL_1" | "FAIL_2";
|
||||
check_type:
|
||||
| "PENDING"
|
||||
| "PRE_CHECK"
|
||||
| "PASS"
|
||||
| "PPAC_CANDIDATE"
|
||||
| "FAIL_1"
|
||||
| "FAIL_2";
|
||||
failed_checks: string[];
|
||||
neutralization: string | null;
|
||||
sub_universe_sharpe: number | null;
|
||||
|
||||
@@ -86,6 +86,7 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "模板名称", exact: true }),
|
||||
).toHaveValue("特征转换模板");
|
||||
await page.getByRole("tab", { name: "回测准备", exact: true }).click();
|
||||
const settingsEditor = page.getByRole("region", {
|
||||
name: "回测参数",
|
||||
exact: true,
|
||||
|
||||
@@ -79,7 +79,7 @@ for (const mode of ["templates", "variants", "features"] as const) {
|
||||
const hypothesis = "检验 <收益> 与规模\n第二轮保留原始假设";
|
||||
await input.fill(hypothesis);
|
||||
await expect(
|
||||
sheet.locator("p").filter({ hasText: /Semi 准备集合 ·/ }),
|
||||
sheet.locator("p:visible").filter({ hasText: /Semi 准备集合 ·/ }),
|
||||
).toBeVisible();
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
@@ -148,6 +148,22 @@ for (const mode of ["templates", "variants", "features"] as const) {
|
||||
sheet.getByRole("textbox", { name: "模板名称", exact: true }),
|
||||
).toHaveValue("AI 生成验收");
|
||||
await expect(generate).toBeEnabled();
|
||||
if (mode === "templates") {
|
||||
await sheet.getByRole("button", { name: "下一步:回测准备" }).click();
|
||||
await expect(
|
||||
sheet.getByRole("textbox", { name: "回测研究假设", exact: true }),
|
||||
).toHaveValue(hypothesis);
|
||||
await expect(
|
||||
sheet.getByRole("button", { name: "生成候选研究集合", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(generate).toBeHidden();
|
||||
await sheet
|
||||
.getByRole("tab", { name: "模板信息编辑", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
sheet.getByRole("textbox", { name: "模板名称", exact: true }),
|
||||
).toHaveValue("AI 生成验收");
|
||||
}
|
||||
await expect(input).toContainText("检验 <收益> 与规模");
|
||||
await page.screenshot({ path: `../output/playwright/semi-ai-${mode}.png` });
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
|
||||
@@ -6,7 +6,7 @@ umask 077
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
# Fail before touching the host; never read a checkout's local .env file.
|
||||
for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY DATABASE_NETWORK PUBLIC_ORIGIN; do
|
||||
for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do
|
||||
if [[ -z "${!key:-}" ]]; then
|
||||
echo "Missing required Gitea configuration: $key" >&2
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user