Simplify template candidate confirmation and direct batch backtesting
Deploy production / deploy (push) Successful in 57s
Deploy production / deploy (push) Successful in 57s
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# 简化模板到批量回测流程
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
模板详情保持编辑、保存、新增版本;回测准备选择数据准备并展开候选,候选集合即用户确认界面,点击回测直接启动批量任务并导航到回测研究。
|
||||||
|
模板生成仅保留表达式语法与数据准备/回测参数组合一致性检查;不持久化逐候选校验状态,不依赖算子/字段可用性缓存。字段类型用于候选域选择,不作为表达式类型检查。
|
||||||
|
模板侧不提供评估研究结果、变体关系;保留回测来源关联。其他研究生产者的行为不扩大修改。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
覆盖语法/组合失败不保存候选、无元数据仍可生成、旧记录不被旧校验状态阻塞、启动幂等与来源、候选分页/选择/直接启动导航。
|
||||||
|
|
||||||
|
## 完成结果
|
||||||
|
已实现模板独立候选确认界面及单次回测接口,内部在同一事务创建执行快照与批量任务;按账户锁和请求键保证重试幂等,提交后唤醒执行器。
|
||||||
|
新模板候选不保存 validation;历史行状态不再参与模板资格判定。旧快照按语法和组合契约读取,其他生产者保留各自检查。
|
||||||
|
模板参数选项同步仅辅助选择,不阻塞生成。修改准备参数会移除旧候选;迟到的生成响应不覆盖新的准备状态。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
- Ruff、前端 typecheck、git diff --check 通过。
|
||||||
|
- 后端研究工作区、MCP模板、研究流水线、批量回测:97项通过。
|
||||||
|
- 现有设置、研究导航、研究结果浏览器回归:6项通过。
|
||||||
|
- Playwright CLI 在隔离模拟环境验证51条候选、跨页选择、40px行高、窄屏滚动、参数变化使候选失效、取消1条后单击回测创建50条任务并导航;无额外预览/评估/变体关系入口,保存来源关联。
|
||||||
|
- 未连接真实平台执行回测,未执行生产数据库变更,未提交或推送代码。
|
||||||
@@ -7,14 +7,14 @@ 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
|
||||||
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 +45,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 +56,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,7 +138,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)
|
||||||
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 = (
|
parents = (
|
||||||
parent_snapshots
|
parent_snapshots
|
||||||
if parent_snapshots is not None
|
if parent_snapshots is not None
|
||||||
@@ -155,9 +157,9 @@ class Experiments:
|
|||||||
if not values:
|
if not values:
|
||||||
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
|
raise HTTPException(422, f"变量 {name} 没有匹配的 {variable.field_type} 字段,请调整数据准备")
|
||||||
for value in values:
|
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} 不在固定输入中或类型不符")
|
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} 未在固定输入中核实")
|
||||||
@@ -170,16 +172,29 @@ class Experiments:
|
|||||||
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(
|
||||||
@@ -187,7 +202,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
|
||||||
@@ -197,16 +212,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 [
|
||||||
@@ -314,11 +334,20 @@ 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"]
|
||||||
|
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(
|
return await Backtests(self.db).preview(
|
||||||
PreviewInput(
|
PreviewInput(
|
||||||
inline=DraftInput(
|
inline=DraftInput(
|
||||||
@@ -345,6 +374,35 @@ class Experiments:
|
|||||||
preserve_source=True,
|
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"):
|
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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]}
|
||||||
)
|
)
|
||||||
@@ -607,3 +632,50 @@ async def test_template_contract_rejects_removed_scope(logged_in):
|
|||||||
assert response.status_code == 422, response.text
|
assert response.status_code == 422, response.text
|
||||||
assert "scope" in response.text
|
assert "scope" in response.text
|
||||||
assert "scope" not in TemplateSpec.model_json_schema()["properties"]
|
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,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>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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";
|
||||||
@@ -83,9 +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 CandidateView =
|
||||||
|
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("");
|
||||||
@@ -99,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;
|
||||||
@@ -118,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(() => {
|
||||||
@@ -261,6 +278,9 @@ export function ResearchWorkspace({
|
|||||||
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");
|
setDetailTab("prepare");
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -527,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}
|
||||||
@@ -552,7 +572,7 @@ export function ResearchWorkspace({
|
|||||||
onChange={setDetailTab}
|
onChange={setDetailTab}
|
||||||
tabPaneMotion={false}
|
tabPaneMotion={false}
|
||||||
tabList={[
|
tabList={[
|
||||||
{ itemKey: "editor", tab: "模板信息编辑" },
|
{ itemKey: "editor", tab: "模板详情" },
|
||||||
{ itemKey: "prepare", tab: "回测准备" },
|
{ itemKey: "prepare", tab: "回测准备" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -807,7 +827,7 @@ export function ResearchWorkspace({
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<Banner
|
<Banner
|
||||||
type="warning"
|
type="warning"
|
||||||
description="请返回模板信息编辑,保存模板版本后再生成候选。"
|
description="请返回模板详情,保存模板版本后再生成候选。"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -845,6 +865,9 @@ export function ResearchWorkspace({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<SimulationSettingsEditor
|
<SimulationSettingsEditor
|
||||||
|
validationMode={
|
||||||
|
page === "templates" ? "combination" : "platform"
|
||||||
|
}
|
||||||
value={settings}
|
value={settings}
|
||||||
onChange={setSettings}
|
onChange={setSettings}
|
||||||
{...options}
|
{...options}
|
||||||
@@ -911,7 +934,7 @@ export function ResearchWorkspace({
|
|||||||
)}
|
)}
|
||||||
<div hidden={page === "templates" && detailTab !== "prepare"}>
|
<div hidden={page === "templates" && detailTab !== "prepare"}>
|
||||||
{experiment && (
|
{experiment && (
|
||||||
<ExperimentView
|
<CandidateView
|
||||||
key={experiment.id}
|
key={experiment.id}
|
||||||
experiment={experiment}
|
experiment={experiment}
|
||||||
onAction={onAction}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -395,3 +395,11 @@
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.research-card .research-candidate-table .semi-checkbox {
|
||||||
|
margin: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.research-candidate-table .workspace-cell code {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user