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 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], [])
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from ..backtests.contracts import RunOutput
|
||||
from ..security import require_auth
|
||||
from .assets import Assets
|
||||
from .comparisons import compare
|
||||
@@ -22,6 +23,7 @@ from .workspace_contracts import (
|
||||
ImportCommit,
|
||||
ImportPreview,
|
||||
SettingVariants,
|
||||
TemplateBacktest,
|
||||
WorkflowSpec,
|
||||
)
|
||||
|
||||
@@ -149,6 +151,14 @@ async def preview(experiment_id: str, body: ExperimentPreview, request: Request)
|
||||
return await Experiments(db).preview(experiment_id, body.candidate_ids)
|
||||
|
||||
|
||||
@router.post("/experiments/{experiment_id}/backtest", status_code=202, response_model=RunOutput)
|
||||
async def template_backtest(experiment_id: str, body: TemplateBacktest, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
result = await Experiments(db).start_template_backtest(experiment_id, body)
|
||||
request.app.state.runner.backtests.wake.set()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/variants/settings", status_code=201)
|
||||
async def settings_variants(body: SettingVariants, request: Request):
|
||||
async with request.app.state.sessions.begin() as db:
|
||||
|
||||
@@ -522,7 +522,8 @@ class ResearchRuntime:
|
||||
"type": "candidates",
|
||||
"experiment_id": experiment["id"],
|
||||
"candidate_ids": [
|
||||
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
|
||||
c["client_item_id"] for c in experiment["candidates"]
|
||||
if experiment["kind"] == "template" or c["validation"]["status"] == "valid"
|
||||
],
|
||||
}
|
||||
ids = step.output["candidate_ids"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "@douyinfe/semi-ui-19";
|
||||
import { WorkspaceTable } from "../components/WorkspaceTable";
|
||||
import type { ResearchCandidate } from "./workspaceTypes";
|
||||
|
||||
/** Selection spans pages; eligibility is supplied by the producing workflow. */
|
||||
export function CandidateTable({
|
||||
candidates,
|
||||
selected,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
requireValidation = false,
|
||||
}: {
|
||||
candidates: ResearchCandidate[];
|
||||
selected: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
requireValidation?: boolean;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
return (
|
||||
<>
|
||||
<WorkspaceTable<ResearchCandidate>
|
||||
dataSource={candidates.slice((page - 1) * pageSize, page * pageSize)}
|
||||
className="research-candidate-table"
|
||||
rowKey="client_item_id"
|
||||
scroll={{ x: 1120, y: 400 }}
|
||||
empty="暂无候选表达式。"
|
||||
rowSelection={{
|
||||
width: 48,
|
||||
disabled,
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => onSelectionChange((keys ?? []).map(String)),
|
||||
getCheckboxProps: (candidate) => ({
|
||||
disabled:
|
||||
disabled ||
|
||||
(requireValidation && candidate.validation?.status !== "valid"),
|
||||
"aria-label": `选择候选 ${candidate.client_item_id}`,
|
||||
title: requireValidation
|
||||
? [
|
||||
...(candidate.validation?.syntax ?? []),
|
||||
...(candidate.validation?.types ?? []),
|
||||
...(candidate.validation?.availability ?? []),
|
||||
].join(";")
|
||||
: undefined,
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "表达式",
|
||||
dataIndex: "expression",
|
||||
width: 420,
|
||||
render: (value) => <code title={value}>{value}</code>,
|
||||
},
|
||||
...(
|
||||
[
|
||||
["neutralization", "Neutralization", 170],
|
||||
["decay", "Decay", 90],
|
||||
["truncation", "Truncation", 120],
|
||||
["maxTrade", "Max Trade", 120],
|
||||
["nanHandling", "NaN Handling", 150],
|
||||
] as const
|
||||
).map(([key, title, width]) => ({
|
||||
key,
|
||||
title,
|
||||
width,
|
||||
render: (_: unknown, candidate: ResearchCandidate) =>
|
||||
candidate.settings[key],
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="table-pagination workspace-table-footer">
|
||||
<span>
|
||||
共 {candidates.length} 个候选 · 已选 {selected.length} 个
|
||||
</span>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
pageSize={pageSize}
|
||||
total={candidates.length}
|
||||
showSizeChanger
|
||||
pageSizeOpts={[25, 50, 100]}
|
||||
preventPageChangeOnPageSizeChange
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
Table,
|
||||
Tag,
|
||||
Toast,
|
||||
} from "@douyinfe/semi-ui-19";
|
||||
import { Banner, Button, Toast } from "@douyinfe/semi-ui-19";
|
||||
import { post, formatTime } from "../api";
|
||||
import type { UIAction } from "../ai/types";
|
||||
import type { Experiment } from "./workspaceTypes";
|
||||
import { EvaluationPanel } from "./EvaluationPanel";
|
||||
import { LineagePanel } from "./LineagePanel";
|
||||
import { ResearchSelect } from "./ResearchSelect";
|
||||
import { validationLabel } from "./workspaceTypes";
|
||||
import { CandidateTable } from "./CandidateTable";
|
||||
import { DeleteResearchButton } from "./DeleteResearchButton";
|
||||
|
||||
export function ExperimentView({
|
||||
@@ -33,7 +26,7 @@ export function ExperimentView({
|
||||
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const valid = experiment.candidates.filter(
|
||||
(c) => c.validation.status === "valid",
|
||||
(c) => c.validation?.status === "valid",
|
||||
);
|
||||
async function preview() {
|
||||
setBusy(true);
|
||||
@@ -72,7 +65,10 @@ export function ExperimentView({
|
||||
生成回测确认预览{selected.length ? `(${selected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user