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