Simplify template candidate confirmation and direct batch backtesting
Deploy production / deploy (push) Successful in 57s

This commit is contained in:
yuxuanhui
2026-09-20 11:01:07 +08:00
parent 07dd767c52
commit 13a2168ca5
14 changed files with 510 additions and 245 deletions
@@ -0,0 +1,22 @@
# 简化模板到批量回测流程
Status: ready-for-agent
## 范围
模板详情保持编辑、保存、新增版本;回测准备选择数据准备并展开候选,候选集合即用户确认界面,点击回测直接启动批量任务并导航到回测研究。
模板生成仅保留表达式语法与数据准备/回测参数组合一致性检查;不持久化逐候选校验状态,不依赖算子/字段可用性缓存。字段类型用于候选域选择,不作为表达式类型检查。
模板侧不提供评估研究结果、变体关系;保留回测来源关联。其他研究生产者的行为不扩大修改。
## 验证
覆盖语法/组合失败不保存候选、无元数据仍可生成、旧记录不被旧校验状态阻塞、启动幂等与来源、候选分页/选择/直接启动导航。
## 完成结果
已实现模板独立候选确认界面及单次回测接口,内部在同一事务创建执行快照与批量任务;按账户锁和请求键保证重试幂等,提交后唤醒执行器。
新模板候选不保存 validation;历史行状态不再参与模板资格判定。旧快照按语法和组合契约读取,其他生产者保留各自检查。
模板参数选项同步仅辅助选择,不阻塞生成。修改准备参数会移除旧候选;迟到的生成响应不覆盖新的准备状态。
## 验证结果
- Ruff、前端 typecheck、git diff --check 通过。
- 后端研究工作区、MCP模板、研究流水线、批量回测:97项通过。
- 现有设置、研究导航、研究结果浏览器回归:6项通过。
- Playwright CLI 在隔离模拟环境验证51条候选、跨页选择、40px行高、窄屏滚动、参数变化使候选失效、取消1条后单击回测创建50条任务并导航;无额外预览/评估/变体关系入口,保存来源关联。
- 未连接真实平台执行回测,未执行生产数据库变更,未提交或推送代码。
+81 -23
View File
@@ -7,14 +7,14 @@ from collections import defaultdict
from fastapi import HTTPException
from sqlalchemy import func, select, update
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source
from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source, StartInput
from ..backtests.service import Backtests, uid
from ..catalog.research_metadata import ResearchMetadata
from ..catalog.service import Catalog
from ..models import Alpha, BacktestRun, CatalogResource, ResearchExperiment
from ..models import Account, Alpha, BacktestPreview, BacktestRun, CatalogResource, ResearchExperiment
from ..preparations.service import Preparations
from .assets import Assets
from .expressions import GROUPS, analyze, expand
from .expressions import GROUPS, ExpressionError, Parser, analyze, expand
from .serialization import encode_snapshot as jsonable_encoder
from .workspace_contracts import TemplateSpec
@@ -45,7 +45,7 @@ class Experiments:
self.catalog = Catalog(db)
self.assets = Assets(db)
async def inputs(self, ids, scope=None):
async def inputs(self, ids, scope=None, *, check_types=True):
if len(set(ids)) != len(ids):
raise HTTPException(422, "输入快照重复")
snapshots = [await self.catalog.input(input_id) for input_id in ids]
@@ -56,7 +56,7 @@ class Experiments:
for name, kind in item["field_types"].items():
if name not in item["field_ids"]:
continue
if name in fields and fields[name] != kind:
if check_types and name in fields and fields[name] != kind:
raise HTTPException(422, f"字段 {name} 在不同快照中类型不一致")
fields[name] = kind
return snapshots, fields
@@ -138,7 +138,9 @@ class Experiments:
asset = await self.assets.get(body.asset_id, body.version, "template") if body.asset_id else None
template = TemplateSpec.model_validate(asset["content"]) if asset else body.template
scope = scope_of(body.settings)
snapshots, fields = await self.inputs(body.input_ids, scope)
snapshots, fields = await self.inputs(body.input_ids, scope, check_types=kind != "template")
if kind == "template" and not snapshots:
raise HTTPException(422, "请先选择数据准备")
parents = (
parent_snapshots
if parent_snapshots is not None
@@ -155,9 +157,9 @@ class Experiments:
if not values:
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
for value in values:
if fields.get(str(value)) != variable.field_type:
if kind != "template" and fields.get(str(value)) != variable.field_type:
raise HTTPException(422, f"变量 {name} 的字段 {value} 不在固定输入中或类型不符")
if variable.kind == "group" and any(
if kind != "template" and variable.kind == "group" and any(
str(v) not in GROUPS and fields.get(str(v)) != "GROUP" for v in variable.values
):
raise HTTPException(422, f"分组变量 {name} 未在固定输入中核实")
@@ -170,16 +172,29 @@ class Experiments:
expanded = expand(template.expression, variables, body.mode, body.limit, body.seed)
except ValueError as exc:
raise HTTPException(422, str(exc)) from None
operators_snapshot = await ResearchMetadata(self.db).get("operators")
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
setting_errors, settings_snapshot = await self.settings_check(body.settings)
availability = await self.field_evidence(scope, fields)
validation_evidence = {}
if kind != "template":
operators_snapshot = await ResearchMetadata(self.db).get("operators")
operators = {item["name"] for item in operators_snapshot["content"].get("items", [])}
setting_errors, settings_snapshot = await self.settings_check(body.settings)
availability = await self.field_evidence(scope, fields)
validation_evidence = {
"field_availability": availability,
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
"operators_snapshot": operators_snapshot,
"settings_snapshot": settings_snapshot,
}
candidates = []
for index, item in enumerate(expanded["items"]):
validation = self.validate(item["expression"], fields, operators, scope, availability)
validation["availability"].extend(setting_errors)
if setting_errors and validation["status"] == "valid":
validation["status"] = "needs_review"
findings = {}
if kind == "template":
self.check_syntax(item["expression"], f"候选 {index + 1}")
else:
validation = self.validate(item["expression"], fields, operators, scope, availability)
validation["availability"].extend(setting_errors)
if setting_errors and validation["status"] == "valid":
validation["status"] = "needs_review"
findings["validation"] = validation
candidates.append(
{
**Candidate(
@@ -187,7 +202,7 @@ class Experiments:
).model_dump(mode="json"),
"bindings": item["bindings"],
"input_ids": list(body.input_ids),
"validation": validation,
**findings,
"changes": [
self.diff(parent.get("expression", ""), item["expression"])
for parent in parents
@@ -197,16 +212,21 @@ class Experiments:
)
evidence = {
"template": asset or {"content": template.model_dump(mode="json")},
"field_availability": availability,
"availability_basis": "各输入的已发布范围目录;若另有字段级证据,须同时满足",
"combination_count": expanded["combination_count"],
"seed": expanded["seed"],
"operators_snapshot": operators_snapshot,
"settings_snapshot": settings_snapshot,
**validation_evidence,
**(extra_evidence or {}),
}
return await self.save(template.name, kind, body.hypothesis, snapshots, parents, candidates, evidence)
@staticmethod
def check_syntax(expression, label="表达式"):
"""Reject unsupported syntax before persistence; platform semantics are not inferred."""
try:
Parser(expression).parse()
except (ExpressionError, RecursionError) as exc:
raise HTTPException(422, f"{label}语法错误:{exc}") from None
@staticmethod
def diff(before, after):
return [
@@ -314,11 +334,20 @@ class Experiments:
candidates = [item for item in candidates if item["client_item_id"] in chosen]
if len(candidates) != len(chosen):
raise HTTPException(422, "选择包含未知候选")
else:
elif experiment["kind"] != "template":
candidates = [item for item in candidates if item["validation"]["status"] == "valid"]
if not candidates or any(item["validation"]["status"] != "valid" for item in candidates):
if not candidates:
raise HTTPException(422, "请至少选择一条候选")
if experiment["kind"] != "template" and any(item["validation"]["status"] != "valid" for item in candidates):
raise HTTPException(422, "候选存在语法、类型或可用性问题,请先解决;至少保留一条已核实候选")
inputs = experiment["inputs"]
if experiment["kind"] == "template":
# Historical collections follow the same syntax/scope contract; old row findings are irrelevant.
for candidate in candidates:
self.check_syntax(candidate["expression"], candidate["client_item_id"])
scope = scope_of(SimulationSettings.model_validate(candidate["settings"]))
if not inputs or any(item["scope"] != scope for item in inputs):
raise HTTPException(422, "数据准备与回测参数组合不一致,请重新生成候选集合")
return await Backtests(self.db).preview(
PreviewInput(
inline=DraftInput(
@@ -345,6 +374,35 @@ class Experiments:
preserve_source=True,
)
async def start_template_backtest(self, experiment_id, body):
"""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, "候选集合已删除")
preview = await self.preview(experiment_id, body.candidate_ids)
return await Backtests(self.db).start(StartInput(
preview_id=preview["preview_id"], version=preview["version"], idempotency_key=body.idempotency_key,
))
async def setting_variants(self, body, *, parent_snapshot=None, extra_evidence=None, kind="variant"):
parents = (
[parent_snapshot] if parent_snapshot is not None else await self.parents([body.alpha_id], [])
+10
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..backtests.contracts import RunOutput
from ..security import require_auth
from .assets import Assets
from .comparisons import compare
@@ -22,6 +23,7 @@ from .workspace_contracts import (
ImportCommit,
ImportPreview,
SettingVariants,
TemplateBacktest,
WorkflowSpec,
)
@@ -149,6 +151,14 @@ async def preview(experiment_id: str, body: ExperimentPreview, request: Request)
return await Experiments(db).preview(experiment_id, body.candidate_ids)
@router.post("/experiments/{experiment_id}/backtest", status_code=202, response_model=RunOutput)
async def template_backtest(experiment_id: str, body: TemplateBacktest, request: Request):
async with request.app.state.sessions.begin() as db:
result = await Experiments(db).start_template_backtest(experiment_id, body)
request.app.state.runner.backtests.wake.set()
return result
@router.post("/variants/settings", status_code=201)
async def settings_variants(body: SettingVariants, request: Request):
async with request.app.state.sessions.begin() as db:
+2 -1
View File
@@ -522,7 +522,8 @@ class ResearchRuntime:
"type": "candidates",
"experiment_id": experiment["id"],
"candidate_ids": [
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
c["client_item_id"] for c in experiment["candidates"]
if experiment["kind"] == "template" or c["validation"]["status"] == "valid"
],
}
ids = step.output["candidate_ids"]
@@ -144,6 +144,11 @@ class ExperimentPreview(Contract):
candidate_ids: list[str] | None = Field(default=None, min_length=1, max_length=10000)
class TemplateBacktest(Contract):
candidate_ids: list[str] = Field(min_length=1, max_length=10000)
idempotency_key: str = Field(min_length=1, max_length=100)
class EvaluationRules(Contract):
version: Literal["research-v1"] = "research-v1"
sharpe_min: float = Field(default=1.0, allow_inf_nan=False)
+1 -1
View File
@@ -138,7 +138,7 @@ async def test_sdk_template_creation_frozen_evidence_and_web_expansion(app, logg
assert {c["expression"] for c in experiment["candidates"]} == {
f"rank({field}) + {offset}" for field in ["TEST_FIN_001", "TEST_FIN_002"] for offset in [0, 1, 5]
}
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
assert all("validation" not in c for c in experiment["candidates"])
assert experiment["evidence"]["template"]["provenance"] == stored["provenance"]
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
+91 -19
View File
@@ -8,7 +8,7 @@ from app.catalog.research_metadata import ResearchMetadata
from app.models import BacktestRun, CatalogResource, ResearchExperiment
from app.research.expressions import analyze, expand
from tests.conftest import alpha
from tests.test_backtests import execute, setup, start
from tests.test_backtests import execute, setup
from tests.test_catalog import SCOPE, prepare, sync
from tests.test_catalog import catalog as catalog_fixture
@@ -120,7 +120,7 @@ def test_bounded_sampling_and_repeated_placeholders():
expand(expression, values, "all", 100)
async def test_template_version_expansion_preview_and_backtest(app, logged_in, research_input):
async def test_template_version_expansion_direct_backtest_and_idempotency(app, logged_in, research_input):
saved = await logged_in.post("/api/v1/research/assets", json={"kind": "template", "content": template()})
assert saved.status_code == 201, saved.text
asset = saved.json()
@@ -130,7 +130,7 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
assert generated.status_code == 201, generated.text
experiment = generated.json()
assert len(experiment["candidates"]) == 2
assert all(c["validation"]["status"] == "valid" for c in experiment["candidates"])
assert all("validation" not in c for c in experiment["candidates"])
modified = template()
modified["expression"] = "-rank({field})"
response = await logged_in.put(
@@ -147,10 +147,24 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
)
).status_code == 409
platform, lane = await setup(app)
preview = await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})
assert preview.status_code == 201, preview.text
assert not platform.posts
run = await start(logged_in, preview.json(), "research-stage-one")
from app.models import BacktestPreview
url = f"/api/v1/research/experiments/{experiment['id']}/backtest"
request = {"candidate_ids": ["c2"], "idempotency_key": "template-confirmation"}
result = await logged_in.post(url, json=request)
assert result.status_code == 202, result.text
run = result.json()
assert run["total"] == 1
assert run["source"]["kind"] == "template"
assert run["source"]["input_snapshot_ids"] == [research_input["id"]]
retry = await logged_in.post(url, json=request)
assert retry.status_code == 202 and retry.json()["backtest_run_id"] == run["backtest_run_id"]
conflict = await logged_in.post(url, json={**request, "candidate_ids": ["c1"]})
assert conflict.status_code == 409
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 1
assert await db.scalar(select(func.count()).select_from(BacktestPreview)) == 1
assert lane.wake.is_set()
await execute(app, lane, run["backtest_run_id"])
results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
aid = results["items"][0]["alpha_id"]
@@ -161,22 +175,34 @@ async def test_template_version_expansion_preview_and_backtest(app, logged_in, r
assert old["backtest_run_ids"] == [run["backtest_run_id"]]
async def test_invalid_fields_and_unknown_operators_never_start(app, logged_in, research_input):
async def test_template_generation_does_not_require_field_operator_or_settings_evidence(app, logged_in, research_input):
from sqlalchemy import delete
async with app.state.sessions.begin() as db:
await db.execute(delete(CatalogResource))
body = expansion(research_input["id"])
body["template"]["variables"]["field"]["values"] = ["other_field"]
assert (await logged_in.post("/api/v1/research/experiments", json=body)).status_code == 422
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
body = expansion(research_input["id"])
body["template"]["expression"] = "made_up({field})"
body["template"]["expression"] = "made_up({field}) + vec_avg(TEST_FIN_001)"
response = await logged_in.post("/api/v1/research/experiments", json=body)
assert response.status_code == 201, response.text
eid = response.json()["id"]
assert (await logged_in.post(f"/api/v1/research/experiments/{eid}/preview", json={})).status_code == 422
experiment = response.json()
assert all("validation" not in c for c in experiment["candidates"])
assert not {"operators_snapshot", "settings_snapshot", "field_availability"} & experiment["evidence"].keys()
assert (await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})).status_code == 201
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
@pytest.mark.parametrize("expression", ["rank({field}", "rank({field},,)", "x = {field}"])
async def test_template_syntax_errors_reject_whole_collection(app, logged_in, research_input, expression):
body = expansion(research_input["id"])
body["template"]["expression"] = expression
response = await logged_in.post("/api/v1/research/experiments", json=body)
assert response.status_code == 422 and "语法错误" in response.text
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
async def test_import_preview_conflict_and_explicit_commit(logged_in):
legacy = {
"name": "legacy",
@@ -367,7 +393,7 @@ def test_actual_cnhk_setting_choice_nesting_is_supported():
)
async def test_published_input_does_not_override_conflicting_field_evidence(app, logged_in, research_input):
async def test_template_candidates_ignore_conflicting_field_evidence(app, logged_in, research_input):
async with app.state.sessions.begin() as db:
await ResearchMetadata(db).publish(
"availability-fixture",
@@ -382,12 +408,11 @@ async def test_published_input_does_not_override_conflicting_field_evidence(app,
experiment = (
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
).json()
assert experiment["candidates"][0]["validation"]["status"] == "needs_review"
assert experiment["candidates"][1]["validation"]["status"] == "valid"
assert all("validation" not in c for c in experiment["candidates"])
denied = await logged_in.post(
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c1"]}
)
assert denied.status_code == 422
assert denied.status_code == 201
duplicate = await logged_in.post(
f"/api/v1/research/experiments/{experiment['id']}/preview", json={"candidate_ids": ["c2", "c2"]}
)
@@ -607,3 +632,50 @@ async def test_template_contract_rejects_removed_scope(logged_in):
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
+93
View File
@@ -0,0 +1,93 @@
import { useState } from "react";
import { Pagination } from "@douyinfe/semi-ui-19";
import { WorkspaceTable } from "../components/WorkspaceTable";
import type { ResearchCandidate } from "./workspaceTypes";
/** Selection spans pages; eligibility is supplied by the producing workflow. */
export function CandidateTable({
candidates,
selected,
onSelectionChange,
disabled = false,
requireValidation = false,
}: {
candidates: ResearchCandidate[];
selected: string[];
onSelectionChange: (ids: string[]) => void;
disabled?: boolean;
requireValidation?: boolean;
}) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
return (
<>
<WorkspaceTable<ResearchCandidate>
dataSource={candidates.slice((page - 1) * pageSize, page * pageSize)}
className="research-candidate-table"
rowKey="client_item_id"
scroll={{ x: 1120, y: 400 }}
empty="暂无候选表达式。"
rowSelection={{
width: 48,
disabled,
selectedRowKeys: selected,
onChange: (keys) => onSelectionChange((keys ?? []).map(String)),
getCheckboxProps: (candidate) => ({
disabled:
disabled ||
(requireValidation && candidate.validation?.status !== "valid"),
"aria-label": `选择候选 ${candidate.client_item_id}`,
title: requireValidation
? [
...(candidate.validation?.syntax ?? []),
...(candidate.validation?.types ?? []),
...(candidate.validation?.availability ?? []),
].join(";")
: undefined,
}),
}}
columns={[
{
title: "表达式",
dataIndex: "expression",
width: 420,
render: (value) => <code title={value}>{value}</code>,
},
...(
[
["neutralization", "Neutralization", 170],
["decay", "Decay", 90],
["truncation", "Truncation", 120],
["maxTrade", "Max Trade", 120],
["nanHandling", "NaN Handling", 150],
] as const
).map(([key, title, width]) => ({
key,
title,
width,
render: (_: unknown, candidate: ResearchCandidate) =>
candidate.settings[key],
})),
]}
/>
<div className="table-pagination workspace-table-footer">
<span>
共 {candidates.length} 个候选 · 已选 {selected.length} 个
</span>
<Pagination
currentPage={page}
pageSize={pageSize}
total={candidates.length}
showSizeChanger
pageSizeOpts={[25, 50, 100]}
preventPageChangeOnPageSizeChange
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</>
);
}
+13 -180
View File
@@ -1,19 +1,12 @@
import { useState } from "react";
import {
Banner,
Button,
Checkbox,
Table,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
import { EvaluationPanel } from "./EvaluationPanel";
import { LineagePanel } from "./LineagePanel";
import { ResearchSelect } from "./ResearchSelect";
import { validationLabel } from "./workspaceTypes";
import { CandidateTable } from "./CandidateTable";
import { DeleteResearchButton } from "./DeleteResearchButton";
export function ExperimentView({
@@ -33,7 +26,7 @@ export function ExperimentView({
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
const [busy, setBusy] = useState(false);
const valid = experiment.candidates.filter(
(c) => c.validation.status === "valid",
(c) => c.validation?.status === "valid",
);
async function preview() {
setBusy(true);
@@ -72,7 +65,10 @@ export function ExperimentView({
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
</Button>
</div>
<p>{experiment.hypothesis}</p>
<details>
<summary>研究描述</summary>
<p>{experiment.hypothesis}</p>
</details>
{experiment.archived ? (
<Banner
type="info"
@@ -92,175 +88,12 @@ export function ExperimentView({
type="info"
description="候选已保存为不可变研究记录。本地校验不保证平台可执行;在回测预览中确认后才开始模拟。"
/>
<div className="research-table-scroll">
<Table<Experiment["candidates"][number]>
className="research-table"
dataSource={experiment.candidates}
rowKey="client_item_id"
pagination={false}
size="small"
style={{ minWidth: 840 }}
empty="暂无候选表达式。"
columns={[
{
title: "选择",
key: "selection",
width: 64,
render: (_, candidate) => (
<Checkbox
aria-label={`选择候选 ${candidate.client_item_id}`}
disabled={candidate.validation.status !== "valid"}
checked={selected.includes(candidate.client_item_id)}
onChange={(event) =>
setSelected((old) =>
event.target.checked
? [...old, candidate.client_item_id]
: old.filter((id) => id !== candidate.client_item_id),
)
}
/>
),
},
{
title: "候选表达式",
key: "expression",
render: (_, candidate) => (
<>
<code>{candidate.expression}</code>
<details>
<summary>绑定与改动</summary>
<pre>
{JSON.stringify(
{
bindings: candidate.bindings,
changes: candidate.changes,
},
null,
2,
)}
</pre>
</details>
</>
),
},
{
title: "市场与设置",
key: "settings",
width: 240,
render: (_, candidate) => (
<>
{candidate.settings.region} / {candidate.settings.universe} /
D{candidate.settings.delay}
<br />
{candidate.settings.neutralization} · decay{" "}
{candidate.settings.decay}
</>
),
},
{
title: "校验",
key: "validation",
width: 180,
render: (_, candidate) => (
<>
<Tag
color={
candidate.validation.status === "valid"
? "green"
: "orange"
}
>
{validationLabel[candidate.validation.status]}
</Tag>
{(["syntax", "types", "availability"] as const).map((key) =>
candidate.validation[key].map((issue, index) => (
<p key={`${key}-${index}`}>
{
{
syntax: "语法",
types: "类型",
availability: "可用性",
}[key]
}
:{issue}
</p>
)),
)}
</>
),
},
]}
/>
</div>
<div className="research-lineage">
<strong>研究来源</strong>
<span>实验 {experiment.id}</span>
{experiment.evidence.template?.id && (
<Button
theme="borderless"
onClick={() =>
onAction({
type: "open_template",
asset_id: experiment.evidence.template!.id,
version: experiment.evidence.template!.version,
nonce: Date.now(),
})
}
>
模板 v{experiment.evidence.template.version}
</Button>
)}
{experiment.inputs.map((input) => (
<Button
key={input.id}
theme="borderless"
onClick={() =>
onAction({
type: "open_research_input",
input_id: input.id,
nonce: Date.now(),
})
}
>
{input.name} · {input.scope.region}/{input.scope.universe}/D
{input.scope.delay}
</Button>
))}
{experiment.parents.map((parent) => (
<Button
key={`${parent.kind}:${parent.id}`}
theme="borderless"
onClick={() =>
onAction(
parent.kind === "alpha"
? {
type: "open_alpha",
alpha_id: parent.id,
nonce: Date.now(),
}
: {
type: "open_experiment",
experiment_id: parent.id,
nonce: Date.now(),
},
)
}
>
父来源:{parent.id}
</Button>
))}
{experiment.backtest_run_ids.map((id) => (
<Button
key={id}
theme="borderless"
onClick={() =>
onAction({ type: "open_backtest", run_id: id, nonce: Date.now() })
}
>
查看关联回测
</Button>
))}
</div>
<CandidateTable
candidates={experiment.candidates}
selected={selected}
onSelectionChange={setSelected}
requireValidation
/>
<div className="inline-actions">
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
+31 -8
View File
@@ -33,6 +33,7 @@ import {
} from "./workspaceTypes";
import { TemplateEditor } from "./TemplateEditor";
import { ExperimentView } from "./ExperimentView";
import { TemplateCandidateSet } from "./TemplateCandidateSet";
import { DeleteResearchButton } from "./DeleteResearchButton";
import { ComparisonPanel } from "./ComparisonPanel";
import "./workspace.css";
@@ -83,9 +84,27 @@ export function ResearchWorkspace({
const [assetTotal, setAssetTotal] = useState(0);
const [historyTotal, setHistoryTotal] = useState(0);
const refreshSequence = useRef(0);
const CandidateView =
experiment?.kind === "template" ? TemplateCandidateSet : ExperimentView;
const selectedInputs = inputs.filter((input) => inputIds.includes(input.id));
const dirty =
!asset || JSON.stringify(template) !== JSON.stringify(asset.content);
const generationKey = JSON.stringify([
asset?.id,
asset?.version,
template,
inputIds,
inputs,
settings,
mode,
limit,
seed,
]);
const currentGeneration = useRef(generationKey);
currentGeneration.current = generationKey;
useEffect(() => {
if (page === "templates" && detailMode === "editor") setExperiment(null);
}, [page, template, inputIds, inputs, settings, mode, limit, seed]);
async function task(label: string, action: () => Promise<void>) {
setBusy(label);
setError("");
@@ -99,13 +118,12 @@ export function ResearchWorkspace({
}
async function refresh() {
const sequence = ++refreshSequence.current;
const [nextAssets, nextInputs, nextHistory] = await Promise.all([
const [nextAssets, nextHistory] = await Promise.all([
api<{ items: Asset[]; total: number }>(
`/research/assets?kind=template&limit=25&offset=${assetPage * 25}&q=${encodeURIComponent(search)}`,
),
Promise.resolve({ items: inputs }),
api<{ items: typeof history; total: number }>(
`/research/experiments?limit=25&offset=${historyPage * 25}`,
`/research/experiments?kind=${page === "templates" ? "template" : "variant"}&limit=25&offset=${historyPage * 25}`,
),
]);
if (sequence !== refreshSequence.current) return;
@@ -118,7 +136,6 @@ export function ResearchWorkspace({
Math.min(current, Math.max(0, Math.ceil(nextHistory.total / 25) - 1)),
);
setAssets(nextAssets.items);
setInputs(nextInputs.items);
setHistory(nextHistory.items);
}
useEffect(() => {
@@ -261,6 +278,9 @@ export function ResearchWorkspace({
seed,
parent_alpha_ids: parent ? parent.split(/[,,\s]+/).filter(Boolean) : [],
});
if (page === "templates" && currentGeneration.current !== generationKey) {
throw new Error("回测准备已改变,请按当前配置重新生成候选集合");
}
setExperiment(next);
setDetailTab("prepare");
await refresh();
@@ -527,7 +547,7 @@ export function ResearchWorkspace({
{error && <Banner type="danger" description={error} />}
{detailMode === "experiment" ? (
experiment && (
<ExperimentView
<CandidateView
key={experiment.id}
experiment={experiment}
onAction={onAction}
@@ -552,7 +572,7 @@ export function ResearchWorkspace({
onChange={setDetailTab}
tabPaneMotion={false}
tabList={[
{ itemKey: "editor", tab: "模板信息编辑" },
{ itemKey: "editor", tab: "模板详情" },
{ itemKey: "prepare", tab: "回测准备" },
]}
/>
@@ -807,7 +827,7 @@ export function ResearchWorkspace({
{dirty && (
<Banner
type="warning"
description="请返回模板信息编辑,保存模板版本后再生成候选。"
description="请返回模板详情,保存模板版本后再生成候选。"
/>
)}
</>
@@ -845,6 +865,9 @@ export function ResearchWorkspace({
</label>
</div>
<SimulationSettingsEditor
validationMode={
page === "templates" ? "combination" : "platform"
}
value={settings}
onChange={setSettings}
{...options}
@@ -911,7 +934,7 @@ export function ResearchWorkspace({
)}
<div hidden={page === "templates" && detailTab !== "prepare"}>
{experiment && (
<ExperimentView
<CandidateView
key={experiment.id}
experiment={experiment}
onAction={onAction}
@@ -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>
);
}
+8
View File
@@ -395,3 +395,11 @@
padding-top: 12px;
}
}
.research-card .research-candidate-table .semi-checkbox {
margin: 0;
flex-direction: row;
}
.research-candidate-table .workspace-cell code {
white-space: nowrap;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export type InputSnapshot = {
created_at: string;
};
export type ResearchCandidate = Candidate & {
validation: {
validation?: {
status: string;
syntax: string[];
types: string[];
@@ -28,7 +28,9 @@ export function SimulationSettingsEditor({
fixedScopes,
disabled = false,
onValidityChange,
validationMode = "platform",
}: {
validationMode?: "platform" | "combination";
alphaType?: "REGULAR" | "SUPER";
value: SimulationSettings;
onChange: (value: SimulationSettings) => void;
@@ -43,18 +45,36 @@ export function SimulationSettingsEditor({
}) {
const scope = settingsScope(value);
const row = rows.find((r) => sameScope(r, scope));
const errors = settingsErrors(value, rows, fixedScopes);
const combinationOnly = validationMode === "combination";
const errors = combinationOnly
? !fixedScopes?.length || fixedScopes.some((s) => !sameScope(s, scope))
? ["所选数据准备必须具有相同组合,并与回测组合一致"]
: []
: settingsErrors(value, rows, fixedScopes);
const [scopeReady, setScopeReady] = useState(false);
const valid =
!loading &&
!error &&
!errors.length &&
(fixedScopes !== undefined || scopeReady);
const valid = combinationOnly
? !errors.length
: !loading &&
!error &&
!errors.length &&
(fixedScopes !== undefined || scopeReady);
useEffect(() => {
onValidityChange?.(valid);
}, [valid, onValidityChange]);
function field(key: string) {
const options = fieldOptions(row, key);
const options = combinationOnly
? key === "neutralization"
? {
choices: [
...new Set([
value.neutralization,
...(row?.neutralizations ??
rows.flatMap((r) => r.neutralizations)),
]),
],
}
: fieldOptions(undefined, key)
: fieldOptions(row, key);
const current =
value[key as keyof SimulationSettings] ??
(key === "maxPosition" ? "OFF" : undefined);
@@ -71,7 +91,7 @@ export function SimulationSettingsEditor({
value={typeof current === "boolean" ? String(current) : current}
disabled={
disabled ||
loading ||
(loading && !combinationOnly) ||
!options.choices.length ||
(options.choices.length === 1 && !message)
}
@@ -97,7 +117,7 @@ export function SimulationSettingsEditor({
? current
: undefined
}
disabled={disabled || loading}
disabled={disabled || (loading && !combinationOnly)}
min={options.minimum}
max={options.maximum}
precision={
@@ -129,16 +149,30 @@ export function SimulationSettingsEditor({
选项更新时间:{formatTime(fetchedAt ?? null)}
</small>
{error && (
<Banner type="warning" description={`${error};请重试同步合法设置`} />
<Banner
type="warning"
description={
combinationOnly
? "参数选项暂不可用,可使用当前参数继续生成候选。"
: `${error};请重试同步合法设置`
}
/>
)}
{!loading && !rows.length && !error && (
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
<Banner
type="info"
description={
combinationOnly
? "可同步参数选项辅助选择,当前参数仍可用于生成候选。"
: "尚无参数选项,请先同步合法设置。"
}
/>
)}
<ScopePicker
value={scope}
options={asScopeOptions(rows)}
readOnly={fixedScopes !== undefined}
disabled={disabled || loading}
disabled={disabled || (loading && !combinationOnly)}
onValidityChange={setScopeReady}
onChange={(next) =>
onChange({