feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s
Deploy production / deploy (push) Successful in 54s
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# 统一目录工具栏与验证
|
||||||
|
Type: task
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
- [x] 共用工具栏、列配置和图标操作
|
||||||
|
- [x] 三个列表接入筛选、排序与列显隐
|
||||||
|
- [x] 必需的服务端排序支持
|
||||||
|
- [x] 类型/构建、后端与浏览器验证
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
用户已授权开发;保留上一轮组合/参数组件改动。
|
||||||
|
|
||||||
|
|
||||||
|
## 验收结果
|
||||||
|
2026-09-12 本地实现完成:三个主列表使用共用筛选/列设置/排序工具栏,常用动作图标化;列选择保存在各自 localStorage 键。数据准备按名称、创建时间、更新时间在服务端排序,默认更新时间降序;在线字段排序保留禁用说明。
|
||||||
|
|
||||||
|
验证:后端目录/数据准备/参数元数据相关 38 项测试通过,ruff 通过;前端构建通过。目录与参数组件共 8 个浏览器用例已通过(首轮 6 项通过,修正复选框测试定位和新建图标无障碍名称后,失败的 2 项分别复测通过);新增组合筛选嵌套交互也通过。检查了桌面筛选/排序截图,已有用例验证 40px 行高、截断、窄屏底栏、跨页选择和在线查询约束。测试使用隔离模拟平台,未访问真实 WQ;构建仍提示已有依赖 eval 与大包体积警告。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 统一目录表格交互
|
||||||
|
|
||||||
|
用户要求数据目录、字段目录、数据准备的表格交互与 Alpha 管理一致。统一筛选浮层(草稿/查询应用/重置)、列显隐持久化、独立排序浮层与左右工具栏;常用动作图标化,保留 aria-label 与提示。保留已完成的组合组件;不新增未授权的保存视图、导出或上游排序能力。服务端分页保持全量排序,在线字段仍需明确条件并点击查询,切换范围清空结果/勾选。
|
||||||
|
|
||||||
|
本地验证覆盖筛选生效时机、排序跨页、列配置刷新恢复、图标名称、40px 行高和窄屏布局。保留前一任务全部改动。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 实现与验证
|
||||||
|
Type: task
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
- [x] 补齐设置选项元数据与测试
|
||||||
|
- [x] 实现 ScopePicker 与 SimulationSettingsEditor
|
||||||
|
- [x] 接入目录及三个回测入口
|
||||||
|
- [x] 类型检查、后端测试、浏览器回归
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
用户已批准方案,本轮开始实现;不修改旧项目。
|
||||||
|
|
||||||
|
实现完成(2026-09-12)。新增共享选项模型、ScopePicker、SimulationSettingsEditor;目录通过 ScopeControls 兼容入口复用,三个回测入口使用同一编辑器。补齐服务端字段 choices/数值范围及 Delay/Universe 依赖解析,保留快照兼容。移除隐式 JSON 覆盖,历史 JSON 逐项校验且不覆盖原文。
|
||||||
|
|
||||||
|
验证:后端相关 60 项通过;ruff check app tests 通过;前端 build(含类型检查)通过。前端相关 12 项验收通过(backtests 3、dataset 3、research-flows 1、research-outcomes 1、settings 4);最后的选项过滤及收起态提示变更另复跑 settings 4 项通过。已查看桌面/390px 参数截图和集合内嵌选择面板截图。测试使用隔离数据库与模拟上游,没有访问真实 WorldQuant 或部署。构建保留既有 lottie eval 与包体积警告。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 通用组合与回测参数组件
|
||||||
|
|
||||||
|
用户已确认开发。组合采用左摘要、右选择按钮,展开后以临时值编辑,应用时原子提交;取消不影响调用方。保留平台、本地与固定输入三种范围语义。回测表单复用组合选择;有 choices 的字段使用下拉,范围数值使用数字输入。参数选项与联动校验集中维护,历史非法值可见但不可预览。移除共用参数 JSON 隐式覆盖,保留独立候选 JSON 模式及服务端校验。
|
||||||
|
|
||||||
|
接入数据准备、数据/字段目录、回测准备、研究工作台、自动研究启动。补齐设置快照字段元数据,保留已有 items 兼容性。验收覆盖选项解析、联动/取消、固定范围、错误重试、最终参数一致性与窄屏。
|
||||||
@@ -30,7 +30,7 @@ def setting_rows(data):
|
|||||||
try:
|
try:
|
||||||
children = data["actions"]["POST"]["settings"]["children"]
|
children = data["actions"]["POST"]["settings"]["children"]
|
||||||
|
|
||||||
def choices(key, instrument=None, region=None):
|
def choices(key, instrument=None, region=None, delay=None, universe=None):
|
||||||
value = children[key]["choices"]
|
value = children[key]["choices"]
|
||||||
if isinstance(value, dict) and "instrumentType" in value:
|
if isinstance(value, dict) and "instrumentType" in value:
|
||||||
value = value["instrumentType"][instrument]
|
value = value["instrumentType"][instrument]
|
||||||
@@ -38,6 +38,14 @@ def setting_rows(data):
|
|||||||
value = value[instrument]
|
value = value[instrument]
|
||||||
if isinstance(value, dict) and "region" in value:
|
if isinstance(value, dict) and "region" in value:
|
||||||
value = value["region"][region]
|
value = value["region"][region]
|
||||||
|
for dimension, selected in (("delay", delay), ("universe", universe)):
|
||||||
|
if isinstance(value, dict) and dimension in value:
|
||||||
|
branches = value[dimension]
|
||||||
|
if not isinstance(branches, dict):
|
||||||
|
raise ValueError()
|
||||||
|
value = branches.get(str(selected), branches.get(selected))
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValueError()
|
||||||
return [item["value"] for item in value]
|
return [item["value"] for item in value]
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
@@ -48,12 +56,29 @@ def setting_rows(data):
|
|||||||
for delay in choices("delay", instrument, region):
|
for delay in choices("delay", instrument, region):
|
||||||
if type(delay) is not int or delay not in (0, 1):
|
if type(delay) is not int or delay not in (0, 1):
|
||||||
continue
|
continue
|
||||||
for universe in choices("universe", instrument, region):
|
for universe in choices("universe", instrument, region, delay):
|
||||||
neutralizations = (
|
neutralizations = (
|
||||||
choices("neutralization", instrument, region)
|
choices("neutralization", instrument, region, delay, universe)
|
||||||
if "neutralization" in children
|
if "neutralization" in children
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
fields = {}
|
||||||
|
for key in (
|
||||||
|
"decay", "truncation", "pasteurization", "unitHandling",
|
||||||
|
"nanHandling", "language", "visualization", "maxTrade", "maxPosition",
|
||||||
|
):
|
||||||
|
definition = children.get(key)
|
||||||
|
if not isinstance(definition, dict):
|
||||||
|
continue
|
||||||
|
field = {}
|
||||||
|
if "choices" in definition:
|
||||||
|
field["choices"] = choices(key, instrument, region, delay, universe)
|
||||||
|
if any(type(v) not in (str, int, float, bool) for v in field["choices"]):
|
||||||
|
raise ValueError()
|
||||||
|
for source, target in (("min_value", "minimum"), ("max_value", "maximum")):
|
||||||
|
if type(definition.get(source)) in (int, float):
|
||||||
|
field[target] = definition[source]
|
||||||
|
fields[key] = field
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"instrument_type": instrument,
|
"instrument_type": instrument,
|
||||||
@@ -61,6 +86,7 @@ def setting_rows(data):
|
|||||||
"universe": universe,
|
"universe": universe,
|
||||||
"delay": delay,
|
"delay": delay,
|
||||||
"neutralizations": neutralizations,
|
"neutralizations": neutralizations,
|
||||||
|
"fields": fields,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Authenticated preparation and field-directory endpoints."""
|
"""Authenticated preparation and field-directory endpoints."""
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
@@ -55,9 +55,11 @@ async def preparations(
|
|||||||
scope_key: str | None = None,
|
scope_key: str | None = None,
|
||||||
limit: int = Query(25, ge=1, le=100),
|
limit: int = Query(25, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
|
sort: Literal["name", "created_at", "updated_at"] = "updated_at",
|
||||||
|
direction: Literal["asc", "desc"] = "desc",
|
||||||
):
|
):
|
||||||
async with request.app.state.sessions() as db:
|
async with request.app.state.sessions() as db:
|
||||||
return await Preparations(db).list(q, scope_key, limit, offset)
|
return await Preparations(db).list(q, scope_key, limit, offset, sort, direction)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/data-preparations", status_code=201)
|
@router.post("/data-preparations", status_code=201)
|
||||||
|
|||||||
@@ -138,9 +138,12 @@ class Preparations:
|
|||||||
async def online_fields(self, filters):
|
async def online_fields(self, filters):
|
||||||
text_filters = (filters.q, filters.dataset_id, filters.field_type)
|
text_filters = (filters.q, filters.dataset_id, filters.field_type)
|
||||||
numeric_filters = (
|
numeric_filters = (
|
||||||
filters.coverage_min, filters.coverage_max,
|
filters.coverage_min,
|
||||||
filters.user_count_min, filters.user_count_max,
|
filters.coverage_max,
|
||||||
filters.alpha_count_min, filters.alpha_count_max,
|
filters.user_count_min,
|
||||||
|
filters.user_count_max,
|
||||||
|
filters.alpha_count_min,
|
||||||
|
filters.alpha_count_max,
|
||||||
)
|
)
|
||||||
if not any(value and value.strip() for value in text_filters) and not any(
|
if not any(value and value.strip() for value in text_filters) and not any(
|
||||||
value is not None for value in numeric_filters
|
value is not None for value in numeric_filters
|
||||||
@@ -304,7 +307,7 @@ class Preparations:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def list(self, q="", scope_key=None, limit=25, offset=0):
|
async def list(self, q="", scope_key=None, limit=25, offset=0, sort="updated_at", direction="desc"):
|
||||||
query = select(DataPreparation)
|
query = select(DataPreparation)
|
||||||
if scope_key:
|
if scope_key:
|
||||||
query = query.where(DataPreparation.scope_key == scope_key)
|
query = query.where(DataPreparation.scope_key == scope_key)
|
||||||
@@ -316,9 +319,13 @@ class Preparations:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
total = await self.db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
rows = await self.db.scalars(
|
column = {
|
||||||
query.order_by(DataPreparation.updated_at.desc(), DataPreparation.id).limit(limit).offset(offset)
|
"name": DataPreparation.name,
|
||||||
)
|
"created_at": DataPreparation.created_at,
|
||||||
|
"updated_at": DataPreparation.updated_at,
|
||||||
|
}[sort]
|
||||||
|
order = column.asc() if direction == "asc" else column.desc()
|
||||||
|
rows = await self.db.scalars(query.order_by(order, DataPreparation.id).limit(limit).offset(offset))
|
||||||
return page([await self.output(row) for row in rows], total, limit, offset)
|
return page([await self.output(row) for row in rows], total, limit, offset)
|
||||||
|
|
||||||
async def members(self, preparation_id, q="", dataset_id=None, limit=25, offset=0):
|
async def members(self, preparation_id, q="", dataset_id=None, limit=25, offset=0):
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Preparation sorting applies before pagination and rejects unsupported keys."""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preparation_sort_pagination(logged_in):
|
||||||
|
client = logged_in
|
||||||
|
scope = {"instrument_type": "EQUITY", "region": "USA", "universe": "TOP3000", "delay": 1}
|
||||||
|
for name in ["sort-C", "sort-A", "sort-B", "unrelated"]:
|
||||||
|
response = await client.post("/api/v1/data-preparations", json={"name": name, "scope": scope})
|
||||||
|
assert response.status_code == 201
|
||||||
|
for direction, expected in [
|
||||||
|
("asc", ["sort-A", "sort-B", "sort-C"]),
|
||||||
|
("desc", ["sort-C", "sort-B", "sort-A"]),
|
||||||
|
]:
|
||||||
|
names = []
|
||||||
|
for offset in range(3):
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/data-preparations",
|
||||||
|
params={
|
||||||
|
"q": "sort-",
|
||||||
|
"sort": "name",
|
||||||
|
"direction": direction,
|
||||||
|
"limit": 1,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total"] == 3
|
||||||
|
names.append(response.json()["items"][0]["name"])
|
||||||
|
assert names == expected
|
||||||
|
for params in [{"sort": "field_count"}, {"direction": "sideways"}]:
|
||||||
|
assert (await client.get("/api/v1/data-preparations", params=params)).status_code == 422
|
||||||
|
default = (await client.get("/api/v1/data-preparations")).json()
|
||||||
|
assert default["items"][0]["name"] == "unrelated"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Settings metadata retains platform choices, numeric bounds and scope dependencies."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.catalog.research_metadata import ResearchMetadata, setting_rows
|
||||||
|
from tests.catalog_fake import platform_response
|
||||||
|
|
||||||
|
|
||||||
|
def settings_response():
|
||||||
|
data = platform_response()
|
||||||
|
children = data["actions"]["POST"]["settings"]["children"]
|
||||||
|
children["neutralization"] = {"choices": [{"value": "INDUSTRY"}, {"value": "NONE"}]}
|
||||||
|
children["decay"] = {"min_value": 0, "max_value": 500}
|
||||||
|
children["truncation"] = {"choices": [{"value": 0.05}, {"value": 0.08}]}
|
||||||
|
children["visualization"] = {"choices": [{"value": False}, {"value": True}]}
|
||||||
|
children["nanHandling"] = {"choices": {"instrumentType": {"EQUITY": {"region": {
|
||||||
|
region: {"delay": {str(delay): [{"value": "OFF" if delay == 0 else "ON"}] for delay in [0, 1]}}
|
||||||
|
for region in ["USA", "CHN", "IND"]
|
||||||
|
}}}}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_preserves_types_and_resolves_delay():
|
||||||
|
rows = setting_rows(settings_response())
|
||||||
|
first = next(r for r in rows if r["region"] == "USA" and r["delay"] == 0)
|
||||||
|
second = next(r for r in rows if r["region"] == "USA" and r["delay"] == 1)
|
||||||
|
assert first["fields"]["decay"] == {"minimum": 0, "maximum": 500}
|
||||||
|
assert first["fields"]["truncation"]["choices"] == [0.05, 0.08]
|
||||||
|
assert first["fields"]["visualization"]["choices"] == [False, True]
|
||||||
|
assert first["fields"]["nanHandling"]["choices"] == ["OFF"]
|
||||||
|
assert second["fields"]["nanHandling"]["choices"] == ["ON"]
|
||||||
|
assert first["neutralizations"] == ["INDUSTRY", "NONE"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_refresh_publishes_fields_and_keeps_previous_snapshot_on_invalid_options(app):
|
||||||
|
class Client:
|
||||||
|
async def research_setting_options(self):
|
||||||
|
return settings_response()
|
||||||
|
|
||||||
|
async with app.state.sessions.begin() as db:
|
||||||
|
service = ResearchMetadata(db, Client())
|
||||||
|
previous = await service.refresh_settings()
|
||||||
|
assert previous["content"]["items"][0]["fields"]["visualization"]["choices"] == [False, True]
|
||||||
|
|
||||||
|
class InvalidClient:
|
||||||
|
async def research_setting_options(self):
|
||||||
|
data = settings_response()
|
||||||
|
data["actions"]["POST"]["settings"]["children"]["nanHandling"]["choices"] = {"unsupported": []}
|
||||||
|
return data
|
||||||
|
|
||||||
|
service.client = InvalidClient()
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
await service.refresh_settings()
|
||||||
|
assert error.value.status_code == 502
|
||||||
|
assert (await service.get("settings"))["content"] == previous["content"]
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
||||||
|
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||||
|
import { settingsErrors } from "../settings/model";
|
||||||
import {
|
import {
|
||||||
ResearchDataInput,
|
ResearchDataInput,
|
||||||
researchSelection,
|
researchSelection,
|
||||||
@@ -104,7 +107,11 @@ export function BacktestPage({
|
|||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [mode, setMode] = useState("lines");
|
const [mode, setMode] = useState("lines");
|
||||||
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
||||||
const [advanced, setAdvanced] = useState("");
|
const options = useSettingsOptions(active && editor);
|
||||||
|
const [settingsValid, setSettingsValid] = useState(false);
|
||||||
|
const fixedScopes = inputIds.length
|
||||||
|
? inputs.filter((r) => inputIds.includes(r.id)).map((r) => r.scope)
|
||||||
|
: undefined;
|
||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [excluded, setExcluded] = useState<string[]>([]);
|
const [excluded, setExcluded] = useState<string[]>([]);
|
||||||
const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>(
|
const [referenceUrls, setReferenceUrls] = useState<Record<string, string>>(
|
||||||
@@ -250,12 +257,13 @@ export function BacktestPage({
|
|||||||
setText("");
|
setText("");
|
||||||
setSettings(initialSettings);
|
setSettings(initialSettings);
|
||||||
setMode("lines");
|
setMode("lines");
|
||||||
setAdvanced("");
|
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setEditor(true);
|
setEditor(true);
|
||||||
setRunId("");
|
setRunId("");
|
||||||
}
|
}
|
||||||
function input() {
|
function input() {
|
||||||
|
if (options.loading || options.error)
|
||||||
|
throw new Error("请等待参数选项加载完成,或重试同步合法设置");
|
||||||
const candidates: Candidate[] =
|
const candidates: Candidate[] =
|
||||||
mode === "json"
|
mode === "json"
|
||||||
? JSON.parse(text)
|
? JSON.parse(text)
|
||||||
@@ -266,13 +274,20 @@ export function BacktestPage({
|
|||||||
.map((expression, i) => ({
|
.map((expression, i) => ({
|
||||||
client_item_id: `item-${i + 1}`,
|
client_item_id: `item-${i + 1}`,
|
||||||
expression,
|
expression,
|
||||||
settings: {
|
settings: { ...settings },
|
||||||
...settings,
|
|
||||||
...(advanced.trim() ? JSON.parse(advanced) : {}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
if (!Array.isArray(candidates) || !candidates.length)
|
if (!Array.isArray(candidates) || !candidates.length)
|
||||||
throw new Error("请提供非空候选集合");
|
throw new Error("请提供非空候选集合");
|
||||||
|
for (const [index, candidate] of candidates.entries()) {
|
||||||
|
if (!candidate || !candidate.settings)
|
||||||
|
throw new Error(`候选 ${index + 1} 缺少完整参数`);
|
||||||
|
// Defaults match the server contract; explicit imported values always win.
|
||||||
|
const resolved = { ...initialSettings, ...candidate.settings };
|
||||||
|
const issues = settingsErrors(resolved, options.rows, fixedScopes);
|
||||||
|
if (issues.length)
|
||||||
|
throw new Error(`候选 ${index + 1}:${issues.join(";")}`);
|
||||||
|
candidate.settings = resolved;
|
||||||
|
}
|
||||||
return { name, source, candidates, ...researchSelection(inputIds, inputs) };
|
return { name, source, candidates, ...researchSelection(inputIds, inputs) };
|
||||||
}
|
}
|
||||||
async function loadDraft(id: string) {
|
async function loadDraft(id: string) {
|
||||||
@@ -488,7 +503,7 @@ export function BacktestPage({
|
|||||||
<SideSheet
|
<SideSheet
|
||||||
title={preview ? "确认回测输入" : "准备候选"}
|
title={preview ? "确认回测输入" : "准备候选"}
|
||||||
visible={editor && drawerVisible}
|
visible={editor && drawerVisible}
|
||||||
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
|
||||||
style={{ right: chatOffset }}
|
style={{ right: chatOffset }}
|
||||||
maskStyle={{ right: chatOffset }}
|
maskStyle={{ right: chatOffset }}
|
||||||
onCancel={() => setEditor(false)}
|
onCancel={() => setEditor(false)}
|
||||||
@@ -614,12 +629,24 @@ export function BacktestPage({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="backtest-toolbar">
|
<div className="backtest-toolbar">
|
||||||
<Button disabled={busy} onClick={() => void perform(save)}>
|
<Button
|
||||||
|
disabled={
|
||||||
|
busy ||
|
||||||
|
options.loading ||
|
||||||
|
(mode === "lines" && !settingsValid)
|
||||||
|
}
|
||||||
|
onClick={() => void perform(save)}
|
||||||
|
>
|
||||||
保存草稿
|
保存草稿
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
loading={busy}
|
loading={busy}
|
||||||
|
disabled={
|
||||||
|
options.loading ||
|
||||||
|
!!options.error ||
|
||||||
|
(mode === "lines" && !settingsValid)
|
||||||
|
}
|
||||||
onClick={() => void perform(prepare)}
|
onClick={() => void perform(prepare)}
|
||||||
>
|
>
|
||||||
预览回测
|
预览回测
|
||||||
@@ -683,94 +710,30 @@ export function BacktestPage({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{mode === "lines" && (
|
{mode === "lines" ? (
|
||||||
<>
|
<SimulationSettingsEditor
|
||||||
<div className="backtest-form-grid">
|
value={settings}
|
||||||
<label>
|
onChange={setSettings}
|
||||||
Region
|
{...options}
|
||||||
<Input
|
fetchedAt={options.fetchedAt}
|
||||||
aria-label="Region"
|
onRefresh={() => void options.refresh()}
|
||||||
placeholder="例如 USA"
|
fixedScopes={fixedScopes}
|
||||||
value={settings.region}
|
disabled={busy}
|
||||||
onChange={(region) =>
|
onValidityChange={setSettingsValid}
|
||||||
setSettings({ ...settings, region })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
) : (
|
||||||
<label>
|
<div className="settings-actions">
|
||||||
Universe
|
<Button
|
||||||
<Input
|
loading={options.loading}
|
||||||
aria-label="Universe"
|
onClick={() => void options.refresh()}
|
||||||
placeholder="例如 TOP3000"
|
>
|
||||||
value={settings.universe}
|
同步合法设置
|
||||||
onChange={(universe) =>
|
</Button>
|
||||||
setSettings({ ...settings, universe })
|
<span>逐项校验完整参数;预览展示最终值。</span>
|
||||||
}
|
{options.error && (
|
||||||
/>
|
<Banner type="warning" description={options.error} />
|
||||||
</label>
|
)}
|
||||||
<label>
|
|
||||||
Delay
|
|
||||||
<Select
|
|
||||||
aria-label="Delay"
|
|
||||||
value={settings.delay}
|
|
||||||
onChange={(v) =>
|
|
||||||
setSettings({
|
|
||||||
...settings,
|
|
||||||
delay: Number(v) as 0 | 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
optionList={[
|
|
||||||
{ value: 0, label: "0" },
|
|
||||||
{ value: 1, label: "1" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Decay
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Decay"
|
|
||||||
value={settings.decay}
|
|
||||||
min={0}
|
|
||||||
onChange={(v) =>
|
|
||||||
setSettings({ ...settings, decay: Number(v) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Neutralization
|
|
||||||
<Input
|
|
||||||
aria-label="Neutralization"
|
|
||||||
value={settings.neutralization}
|
|
||||||
onChange={(neutralization) =>
|
|
||||||
setSettings({ ...settings, neutralization })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Truncation
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Truncation"
|
|
||||||
value={settings.truncation}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
onChange={(v) =>
|
|
||||||
setSettings({ ...settings, truncation: Number(v) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<details>
|
|
||||||
<summary>其他参数(JSON 覆盖,预览展示最终值)</summary>
|
|
||||||
<TextArea
|
|
||||||
aria-label="其他参数"
|
|
||||||
value={advanced}
|
|
||||||
onChange={setAdvanced}
|
|
||||||
placeholder={'{"nanHandling":"ON"}'}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</details>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
{mode === "lines" ? "表达式" : "候选 JSON"}
|
{mode === "lines" ? "表达式" : "候选 JSON"}
|
||||||
@@ -797,7 +760,7 @@ export function BacktestPage({
|
|||||||
<SideSheet
|
<SideSheet
|
||||||
title={run?.name || "回测详情"}
|
title={run?.name || "回测详情"}
|
||||||
visible={!!runId && drawerVisible}
|
visible={!!runId && drawerVisible}
|
||||||
width={`min(60vw, calc(100vw - ${chatOffset}px))`}
|
width={`min(max(640px, 60vw), calc(100vw - ${chatOffset}px))`}
|
||||||
style={{ right: chatOffset }}
|
style={{ right: chatOffset }}
|
||||||
maskStyle={{ right: chatOffset }}
|
maskStyle={{ right: chatOffset }}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const initialSettings: SimulationSettings = {
|
|||||||
language: "FASTEXPR",
|
language: "FASTEXPR",
|
||||||
visualization: false,
|
visualization: false,
|
||||||
maxTrade: "OFF",
|
maxTrade: "OFF",
|
||||||
|
maxPosition: "OFF",
|
||||||
};
|
};
|
||||||
export type Candidate = {
|
export type Candidate = {
|
||||||
client_item_id: string;
|
client_item_id: string;
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { Button, CheckboxGroup, Popover, Tooltip } from "@douyinfe/semi-ui-19";
|
||||||
|
import {
|
||||||
|
IconClose,
|
||||||
|
IconFilter,
|
||||||
|
IconSetting,
|
||||||
|
IconSort,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import { ResearchSelect } from "../research/ResearchSelect";
|
||||||
|
import "./catalog-table-toolbar.css";
|
||||||
|
|
||||||
|
export type ColumnChoice = { key: string; label: string; required?: boolean };
|
||||||
|
/** Each catalog keeps its own columns. Ignore stale or malformed saved keys. */
|
||||||
|
export function useCatalogColumns(storageKey: string, choices: ColumnChoice[]) {
|
||||||
|
const [visible, setVisible] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved: unknown = JSON.parse(
|
||||||
|
localStorage.getItem(storageKey) ?? "null",
|
||||||
|
);
|
||||||
|
if (Array.isArray(saved))
|
||||||
|
return choices
|
||||||
|
.filter((c) => c.required || saved.includes(c.key))
|
||||||
|
.map((c) => c.key);
|
||||||
|
} catch {
|
||||||
|
/* A storage restriction must not prevent reading the catalog. */
|
||||||
|
}
|
||||||
|
return choices.map((c) => c.key);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
choices,
|
||||||
|
visible,
|
||||||
|
onChange: (keys: string[]) => {
|
||||||
|
const next = choices
|
||||||
|
.filter((c) => c.required || keys.includes(c.key))
|
||||||
|
.map((c) => c.key);
|
||||||
|
setVisible(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* Session-only preferences remain usable. */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function CatalogIconAction({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof Button> & { label: string }) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={props.title ?? label}>
|
||||||
|
<span className="catalog-icon-action">
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
{...props}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** Shared interaction shell; callers own draft filters, remote sorting and pagination. */
|
||||||
|
export function CatalogTableToolbar({
|
||||||
|
label,
|
||||||
|
active = true,
|
||||||
|
filterCount = 0,
|
||||||
|
filters,
|
||||||
|
onApply,
|
||||||
|
onReset,
|
||||||
|
canApply = true,
|
||||||
|
columns,
|
||||||
|
sort,
|
||||||
|
actions,
|
||||||
|
selection,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
filterCount?: number;
|
||||||
|
filters: ReactNode;
|
||||||
|
onApply: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
canApply?: boolean;
|
||||||
|
columns: ReturnType<typeof useCatalogColumns>;
|
||||||
|
sort?: {
|
||||||
|
value: string;
|
||||||
|
direction: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
onChange: (value: string, direction: string) => void;
|
||||||
|
};
|
||||||
|
actions?: ReactNode;
|
||||||
|
selection?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<"filter" | "columns" | "sort" | null>(null);
|
||||||
|
const triggerId = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) setOpen(null);
|
||||||
|
}, [active]);
|
||||||
|
const close = () => {
|
||||||
|
setOpen(null);
|
||||||
|
document.getElementById(`${triggerId}-${open}`)?.focus();
|
||||||
|
};
|
||||||
|
const popup = (
|
||||||
|
kind: "filter" | "columns" | "sort",
|
||||||
|
content: ReactNode,
|
||||||
|
icon: ReactNode,
|
||||||
|
name: string,
|
||||||
|
) => (
|
||||||
|
<Popover
|
||||||
|
trigger="custom"
|
||||||
|
position="bottomLeft"
|
||||||
|
visible={active && open === kind}
|
||||||
|
onClickOutSide={() => setOpen(null)}
|
||||||
|
onEscKeyDown={close}
|
||||||
|
content={content}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`${triggerId}-${kind}`}
|
||||||
|
icon={icon}
|
||||||
|
aria-label={name}
|
||||||
|
title={name}
|
||||||
|
type={
|
||||||
|
open === kind || (kind === "filter" && filterCount)
|
||||||
|
? "primary"
|
||||||
|
: "tertiary"
|
||||||
|
}
|
||||||
|
theme={
|
||||||
|
open === kind || (kind === "filter" && filterCount)
|
||||||
|
? "light"
|
||||||
|
: "borderless"
|
||||||
|
}
|
||||||
|
onClick={() => setOpen(open === kind ? null : kind)}
|
||||||
|
>
|
||||||
|
{kind === "filter" && filterCount > 0 && (
|
||||||
|
<span className="catalog-filter-count">{filterCount}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="catalog-table-toolbar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={`${label}表格工具栏`}
|
||||||
|
>
|
||||||
|
<div className="catalog-toolbar-tools">
|
||||||
|
{popup(
|
||||||
|
"filter",
|
||||||
|
<form
|
||||||
|
className="catalog-filter-panel"
|
||||||
|
aria-label={`${label}筛选条件`}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canApply) {
|
||||||
|
onApply();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h2>筛选{label}</h2>
|
||||||
|
<p>设置条件后点击查询,应用到当前列表。</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<IconClose aria-hidden="true" />}
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
aria-label="关闭筛选"
|
||||||
|
onClick={close}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div className="catalog-filter-body">{filters}</div>
|
||||||
|
<footer>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onReset();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close}>取消</Button>
|
||||||
|
<Button htmlType="submit" theme="solid" disabled={!canApply}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</form>,
|
||||||
|
<IconFilter aria-hidden="true" />,
|
||||||
|
`筛选${label}`,
|
||||||
|
)}
|
||||||
|
{popup(
|
||||||
|
"columns",
|
||||||
|
<div className="catalog-column-panel">
|
||||||
|
<strong>显示列</strong>
|
||||||
|
<CheckboxGroup
|
||||||
|
direction="vertical"
|
||||||
|
value={columns.visible}
|
||||||
|
options={columns.choices.map((c) => ({
|
||||||
|
value: c.key,
|
||||||
|
label: c.label,
|
||||||
|
disabled: c.required,
|
||||||
|
}))}
|
||||||
|
onChange={(keys) => columns.onChange(keys.map(String))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() =>
|
||||||
|
columns.onChange(columns.choices.map((c) => c.key))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
恢复默认列
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
<IconSetting aria-hidden="true" />,
|
||||||
|
"显示列设置",
|
||||||
|
)}
|
||||||
|
{sort ? (
|
||||||
|
popup(
|
||||||
|
"sort",
|
||||||
|
<div className="catalog-sort-panel">
|
||||||
|
<h2>排序</h2>
|
||||||
|
<ResearchSelect
|
||||||
|
label="排序字段"
|
||||||
|
value={sort.value}
|
||||||
|
optionList={sort.options}
|
||||||
|
onChange={(v) => sort.onChange(String(v), sort.direction)}
|
||||||
|
/>
|
||||||
|
<ResearchSelect
|
||||||
|
label="排序方向"
|
||||||
|
value={sort.direction}
|
||||||
|
optionList={[
|
||||||
|
{ value: "asc", label: "升序" },
|
||||||
|
{ value: "desc", label: "降序" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => sort.onChange(sort.value, String(v))}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
<IconSort aria-hidden="true" />,
|
||||||
|
"排序设置",
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<CatalogIconAction
|
||||||
|
label="排序设置"
|
||||||
|
title="平台接口未提供排序,按平台返回顺序展示"
|
||||||
|
icon={<IconSort aria-hidden="true" />}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="catalog-toolbar-actions">{actions}</div>
|
||||||
|
</div>
|
||||||
|
{selection && (
|
||||||
|
<div className="catalog-selection-actions">{selection}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
.catalog-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 4px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--semi-color-border);
|
||||||
|
}
|
||||||
|
.catalog-toolbar-tools,
|
||||||
|
.catalog-toolbar-actions,
|
||||||
|
.catalog-selection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.catalog-toolbar-tools {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.catalog-toolbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.catalog-icon-action {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.catalog-selection-actions {
|
||||||
|
padding: 4px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--semi-color-primary-light-default);
|
||||||
|
border-bottom: 1px solid var(--semi-color-border);
|
||||||
|
}
|
||||||
|
.catalog-filter-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.catalog-filter-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
max-height: min(700px, calc(100dvh - 120px));
|
||||||
|
color: var(--semi-color-text-0);
|
||||||
|
}
|
||||||
|
.catalog-filter-panel > header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
.catalog-filter-panel h2,
|
||||||
|
.catalog-sort-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.catalog-filter-panel header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--semi-color-text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.catalog-filter-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
.catalog-filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.catalog-filter-grid > label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.catalog-filter-grid .semi-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.catalog-filter-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.catalog-filter-body details {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.catalog-filter-body details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.catalog-filter-panel > footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--semi-color-border);
|
||||||
|
}
|
||||||
|
.catalog-filter-panel > footer > :first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.catalog-column-panel {
|
||||||
|
padding: 16px;
|
||||||
|
min-width: 192px;
|
||||||
|
max-height: min(480px, 60dvh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.catalog-column-panel > strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.catalog-sort-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 100px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
.catalog-sort-panel > h2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.catalog-sort-panel .semi-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.catalog-filter-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
-110
@@ -1,3 +1,16 @@
|
|||||||
|
import {
|
||||||
|
CatalogTableToolbar,
|
||||||
|
CatalogIconAction,
|
||||||
|
useCatalogColumns,
|
||||||
|
} from "../components/CatalogTableToolbar";
|
||||||
|
import {
|
||||||
|
IconRefresh,
|
||||||
|
IconDownload,
|
||||||
|
IconAIFilledLevel3,
|
||||||
|
IconEyeOpened,
|
||||||
|
IconPlus,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +89,19 @@ export function DatasetPage({
|
|||||||
[q, setQ] = useState(""),
|
[q, setQ] = useState(""),
|
||||||
[category, setCategory] = useState(""),
|
[category, setCategory] = useState(""),
|
||||||
[subcategory, setSubcategory] = useState("");
|
[subcategory, setSubcategory] = useState("");
|
||||||
|
const [filterDraft, setFilterDraft] = useState({
|
||||||
|
q: "",
|
||||||
|
category: "",
|
||||||
|
subcategory: "",
|
||||||
|
});
|
||||||
|
const columnSettings = useCatalogColumns("catalog-dataset-columns", [
|
||||||
|
{ key: "dataset", label: "数据集", required: true },
|
||||||
|
{ key: "category", label: "分类" },
|
||||||
|
{ key: "field_count", label: "字段数" },
|
||||||
|
{ key: "value_score", label: "价值评分" },
|
||||||
|
{ key: "status", label: "同步状态" },
|
||||||
|
{ key: "actions", label: "操作", required: true },
|
||||||
|
]);
|
||||||
const [sort, setSort] = useState("name"),
|
const [sort, setSort] = useState("name"),
|
||||||
[direction, setDirection] = useState("asc"),
|
[direction, setDirection] = useState("asc"),
|
||||||
[page, setPage] = useState(1),
|
[page, setPage] = useState(1),
|
||||||
@@ -223,98 +249,7 @@ export function DatasetPage({
|
|||||||
setNoteVersion(n.version);
|
setNoteVersion(n.version);
|
||||||
Toast.success("备注已保存");
|
Toast.success("备注已保存");
|
||||||
}
|
}
|
||||||
return (
|
const columns: ColumnProps<Entry>[] = [
|
||||||
<section className="preparation-page">
|
|
||||||
<ScopeControls
|
|
||||||
value={scope}
|
|
||||||
onChange={setScope}
|
|
||||||
active={active}
|
|
||||||
revision={version}
|
|
||||||
/>
|
|
||||||
{error && <Banner type="danger" description={error} />}
|
|
||||||
{copied && (
|
|
||||||
<Banner
|
|
||||||
type="success"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{copied.name} 已创建{" "}
|
|
||||||
<a href={`#preparations?id=${copied.id}`}>打开数据准备</a>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="preparation-tools">
|
|
||||||
<Input
|
|
||||||
aria-label="搜索数据集"
|
|
||||||
placeholder="搜索数据集名称或 ID"
|
|
||||||
value={q}
|
|
||||||
onChange={(v) => {
|
|
||||||
setQ(v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ResearchSelect
|
|
||||||
label="分类"
|
|
||||||
placeholder="全部分类"
|
|
||||||
showClear
|
|
||||||
value={category || undefined}
|
|
||||||
optionList={Object.keys(data.categories).map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => {
|
|
||||||
setCategory(String(v ?? ""));
|
|
||||||
setSubcategory("");
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ResearchSelect
|
|
||||||
label="子分类"
|
|
||||||
placeholder="全部子分类"
|
|
||||||
showClear
|
|
||||||
value={subcategory || undefined}
|
|
||||||
optionList={(data.categories[category] ?? []).map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSubcategory(String(v ?? ""));
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ResearchSelect
|
|
||||||
label="数据集排序"
|
|
||||||
value={sort}
|
|
||||||
optionList={[
|
|
||||||
{ value: "name", label: "名称" },
|
|
||||||
{ value: "field_count", label: "字段数" },
|
|
||||||
{ value: "value_score", label: "价值评分" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSort(String(v));
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDirection((v) => (v === "asc" ? "desc" : "asc"))}
|
|
||||||
>
|
|
||||||
{direction === "asc" ? "升序" : "降序"}
|
|
||||||
</Button>
|
|
||||||
<Button disabled={busy} onClick={() => void work(() => sync())}>
|
|
||||||
同步目录
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setRev((v) => v + 1)}>刷新</Button>
|
|
||||||
<Button onClick={onChat}>AI 助手</Button>
|
|
||||||
</div>
|
|
||||||
<Table<Entry>
|
|
||||||
fill
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
dataSource={data.items}
|
|
||||||
pagination={false}
|
|
||||||
loading={loading}
|
|
||||||
scroll={{ x: 1180, y: "100%" }}
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
title: "数据集",
|
title: "数据集",
|
||||||
width: 280,
|
width: 280,
|
||||||
@@ -344,35 +279,174 @@ export function DatasetPage({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 240,
|
width: 140,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div className="workspace-cell-actions">
|
<div className="workspace-cell-actions">
|
||||||
<Button
|
<CatalogIconAction
|
||||||
theme="borderless"
|
label="查看"
|
||||||
|
icon={<IconEyeOpened aria-hidden="true" />}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void work(() => view(r))}
|
onClick={() => void work(() => view(r))}
|
||||||
>
|
/>
|
||||||
查看
|
<CatalogIconAction
|
||||||
</Button>
|
label="同步"
|
||||||
<Button
|
icon={<IconDownload aria-hidden="true" />}
|
||||||
theme="borderless"
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void work(() => sync(r.id))}
|
onClick={() => void work(() => sync(r.id))}
|
||||||
>
|
/>
|
||||||
同步
|
<CatalogIconAction
|
||||||
</Button>
|
label="使用"
|
||||||
<Button
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
theme="borderless"
|
|
||||||
disabled={busy || !r.collection_version}
|
disabled={busy || !r.collection_version}
|
||||||
title={!r.collection_version ? "请先同步全部字段" : undefined}
|
title={!r.collection_version ? "请先同步全部字段" : "使用"}
|
||||||
onClick={() => void work(() => use(r))}
|
onClick={() => void work(() => use(r))}
|
||||||
>
|
/>
|
||||||
使用
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
|
const visibleColumns = columns.filter((_, index) =>
|
||||||
|
columnSettings.visible.includes(columnSettings.choices[index].key),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="preparation-page">
|
||||||
|
<ScopeControls
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
active={active}
|
||||||
|
revision={version}
|
||||||
|
/>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
{copied && (
|
||||||
|
<Banner
|
||||||
|
type="success"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{copied.name} 已创建{" "}
|
||||||
|
<a href={`#preparations?id=${copied.id}`}>打开数据准备</a>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CatalogTableToolbar
|
||||||
|
label="数据目录"
|
||||||
|
active={active && !suspended && !detail}
|
||||||
|
filterCount={[q, category, subcategory].filter(Boolean).length}
|
||||||
|
columns={columnSettings}
|
||||||
|
filters={
|
||||||
|
<div className="catalog-filter-grid">
|
||||||
|
<label className="catalog-filter-wide">
|
||||||
|
搜索
|
||||||
|
<Input
|
||||||
|
aria-label="搜索数据集"
|
||||||
|
placeholder="搜索数据集名称或 ID"
|
||||||
|
value={filterDraft.q}
|
||||||
|
onChange={(q) => setFilterDraft({ ...filterDraft, q })}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
分类
|
||||||
|
<ResearchSelect
|
||||||
|
label="分类"
|
||||||
|
placeholder="全部分类"
|
||||||
|
showClear
|
||||||
|
value={filterDraft.category || undefined}
|
||||||
|
optionList={Object.keys(data.categories).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: v,
|
||||||
|
}))}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilterDraft({
|
||||||
|
...filterDraft,
|
||||||
|
category: String(v ?? ""),
|
||||||
|
subcategory: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
子分类
|
||||||
|
<ResearchSelect
|
||||||
|
label="子分类"
|
||||||
|
placeholder="全部子分类"
|
||||||
|
showClear
|
||||||
|
value={filterDraft.subcategory || undefined}
|
||||||
|
optionList={(data.categories[filterDraft.category] ?? []).map(
|
||||||
|
(v) => ({ value: v, label: v }),
|
||||||
|
)}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFilterDraft({
|
||||||
|
...filterDraft,
|
||||||
|
subcategory: String(v ?? ""),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onApply={() => {
|
||||||
|
setQ(filterDraft.q);
|
||||||
|
setCategory(filterDraft.category);
|
||||||
|
setSubcategory(filterDraft.subcategory);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setFilterDraft({ q: "", category: "", subcategory: "" });
|
||||||
|
setQ("");
|
||||||
|
setCategory("");
|
||||||
|
setSubcategory("");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sort={{
|
||||||
|
value: sort,
|
||||||
|
direction,
|
||||||
|
options: [
|
||||||
|
{ value: "name", label: "名称" },
|
||||||
|
{ value: "field_count", label: "字段数" },
|
||||||
|
{ value: "value_score", label: "价值评分" },
|
||||||
|
],
|
||||||
|
onChange: (value, dir) => {
|
||||||
|
setSort(value);
|
||||||
|
setDirection(dir);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="刷新"
|
||||||
|
icon={<IconRefresh aria-hidden="true" />}
|
||||||
|
onClick={() => setRev((v) => v + 1)}
|
||||||
|
/>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="AI 助手"
|
||||||
|
icon={<IconAIFilledLevel3 aria-hidden="true" />}
|
||||||
|
onClick={onChat}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconDownload aria-hidden="true" />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void work(() => sync())}
|
||||||
|
>
|
||||||
|
同步目录
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table<Entry>
|
||||||
|
fill
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{
|
||||||
|
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
|
||||||
|
y: "100%",
|
||||||
|
}}
|
||||||
|
columns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
<div className="table-pagination workspace-table-footer">
|
<div className="table-pagination workspace-table-footer">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import {
|
||||||
|
CatalogTableToolbar,
|
||||||
|
CatalogIconAction,
|
||||||
|
useCatalogColumns,
|
||||||
|
} from "../components/CatalogTableToolbar";
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconRefresh,
|
||||||
|
IconDelete,
|
||||||
|
IconCopy,
|
||||||
|
IconEyeOpened,
|
||||||
|
IconClose,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table";
|
||||||
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
import { WorkspaceTable as Table } from "../components/WorkspaceTable";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
|
Checkbox,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -39,6 +54,21 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
|
const [selected, setSelected] = useState<Record<string, Preparation>>({}),
|
||||||
[scope, setScope] = useState(defaultScope),
|
[scope, setScope] = useState(defaultScope),
|
||||||
[scopeFilter, setScopeFilter] = useState(false);
|
[scopeFilter, setScopeFilter] = useState(false);
|
||||||
|
const [draftQ, setDraftQ] = useState("");
|
||||||
|
const [filterScope, setFilterScope] = useState(defaultScope);
|
||||||
|
const [draftScope, setDraftScope] = useState(defaultScope);
|
||||||
|
const [draftScopeFilter, setDraftScopeFilter] = useState(false);
|
||||||
|
const [sort, setSort] = useState("updated_at");
|
||||||
|
const [direction, setDirection] = useState("desc");
|
||||||
|
const columnSettings = useCatalogColumns("catalog-preparations-columns", [
|
||||||
|
{ key: "name", label: "集合名称", required: true },
|
||||||
|
{ key: "scope", label: "范围" },
|
||||||
|
{ key: "field_count", label: "字段数" },
|
||||||
|
{ key: "dataset_count", label: "数据集数" },
|
||||||
|
{ key: "updated_at", label: "更新时间" },
|
||||||
|
{ key: "actions", label: "操作", required: true },
|
||||||
|
]);
|
||||||
|
const [scopeValid, setScopeValid] = useState(false);
|
||||||
const [detail, setDetail] = useState<Preparation | null>(null),
|
const [detail, setDetail] = useState<Preparation | null>(null),
|
||||||
[creating, setCreating] = useState(false),
|
[creating, setCreating] = useState(false),
|
||||||
[name, setName] = useState(""),
|
[name, setName] = useState(""),
|
||||||
@@ -61,7 +91,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
api<Page<Preparation>>(
|
api<Page<Preparation>>(
|
||||||
`/data-preparations?${queryString({ q, limit: pageSize, offset: (page - 1) * pageSize, scope_key: scopeFilter ? scopeKey(scope) : undefined })}`,
|
`/data-preparations?${queryString({ q, limit: pageSize, offset: (page - 1) * pageSize, sort, direction, scope_key: scopeFilter ? scopeKey(filterScope) : undefined })}`,
|
||||||
{ signal: c.signal },
|
{ signal: c.signal },
|
||||||
)
|
)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -72,7 +102,17 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
if (!c.signal.aborted) setError(e.message);
|
if (!c.signal.aborted) setError(e.message);
|
||||||
});
|
});
|
||||||
return () => c.abort();
|
return () => c.abort();
|
||||||
}, [active, q, page, pageSize, rev, scopeFilter, scopeKey(scope)]);
|
}, [
|
||||||
|
active,
|
||||||
|
q,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
rev,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
scopeFilter,
|
||||||
|
scopeKey(filterScope),
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
@@ -119,76 +159,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
return (
|
const columns: ColumnProps<Preparation>[] = [
|
||||||
<section className="preparation-page">
|
|
||||||
{error && <Banner type="danger" description={error} />}
|
|
||||||
<div className="preparation-tools">
|
|
||||||
<Input
|
|
||||||
aria-label="搜索准备集合"
|
|
||||||
placeholder="搜索名称或备注"
|
|
||||||
value={q}
|
|
||||||
onChange={(v) => {
|
|
||||||
setQ(v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
theme="solid"
|
|
||||||
onClick={() => {
|
|
||||||
setCreating(true);
|
|
||||||
setName("新数据准备集合");
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
新建集合
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setScopeFilter((v) => !v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scopeFilter ? "全部范围" : "按范围筛选"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
disabled={!Object.keys(selected).length}
|
|
||||||
onClick={() => setDeleting(Object.values(selected))}
|
|
||||||
>
|
|
||||||
删除所选
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{scopeFilter && (
|
|
||||||
<ScopeControls
|
|
||||||
local
|
|
||||||
value={scope}
|
|
||||||
onChange={(s) => {
|
|
||||||
setScope(s);
|
|
||||||
setPage(1);
|
|
||||||
setSelected({});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Table<Preparation>
|
|
||||||
fill
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
dataSource={data.items}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: 1140, y: "100%" }}
|
|
||||||
rowSelection={{
|
|
||||||
selectedRowKeys: Object.keys(selected),
|
|
||||||
onChange: (keys) =>
|
|
||||||
setSelected((old) => {
|
|
||||||
const n: Record<string, Preparation> = {};
|
|
||||||
for (const k of keys ?? []) {
|
|
||||||
const r = data.items.find((x) => x.id === k) ?? old[k];
|
|
||||||
if (r) n[k] = r;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
columns={[
|
|
||||||
{
|
{
|
||||||
title: "集合名称",
|
title: "集合名称",
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -214,14 +185,17 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 200,
|
width: 140,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div className="workspace-cell-actions">
|
<div className="workspace-cell-actions">
|
||||||
<Button theme="borderless" onClick={() => open(r)}>
|
<CatalogIconAction
|
||||||
查看
|
label="查看"
|
||||||
</Button>
|
icon={<IconEyeOpened aria-hidden="true" />}
|
||||||
<Button
|
onClick={() => open(r)}
|
||||||
theme="borderless"
|
/>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="复制"
|
||||||
|
icon={<IconCopy aria-hidden="true" />}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void work(async () => {
|
void work(async () => {
|
||||||
@@ -232,20 +206,147 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
open(copy);
|
open(copy);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
复制
|
<CatalogIconAction
|
||||||
</Button>
|
label="删除"
|
||||||
<Button
|
icon={<IconDelete aria-hidden="true" />}
|
||||||
theme="borderless"
|
|
||||||
type="danger"
|
type="danger"
|
||||||
onClick={() => setDeleting([r])}
|
onClick={() => setDeleting([r])}
|
||||||
>
|
/>
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
|
const visibleColumns = columns.filter((_, i) =>
|
||||||
|
columnSettings.visible.includes(columnSettings.choices[i].key),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="preparation-page">
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
<CatalogTableToolbar
|
||||||
|
label="数据准备"
|
||||||
|
active={active && !creating && !detail && !deleting.length}
|
||||||
|
columns={columnSettings}
|
||||||
|
filterCount={Number(Boolean(q)) + Number(scopeFilter)}
|
||||||
|
filters={
|
||||||
|
<div className="catalog-filter-grid">
|
||||||
|
<label className="catalog-filter-wide">
|
||||||
|
搜索
|
||||||
|
<Input
|
||||||
|
aria-label="搜索准备集合"
|
||||||
|
placeholder="搜索名称或备注"
|
||||||
|
value={draftQ}
|
||||||
|
onChange={setDraftQ}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="catalog-filter-wide">
|
||||||
|
<Checkbox
|
||||||
|
checked={draftScopeFilter}
|
||||||
|
onChange={(e) => setDraftScopeFilter(Boolean(e.target.checked))}
|
||||||
|
>
|
||||||
|
按范围筛选
|
||||||
|
</Checkbox>
|
||||||
|
{draftScopeFilter && (
|
||||||
|
<ScopeControls
|
||||||
|
local
|
||||||
|
value={draftScope}
|
||||||
|
onChange={setDraftScope}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onApply={() => {
|
||||||
|
setQ(draftQ);
|
||||||
|
setScopeFilter(draftScopeFilter);
|
||||||
|
setFilterScope(draftScope);
|
||||||
|
setPage(1);
|
||||||
|
setSelected({});
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setQ("");
|
||||||
|
setDraftQ("");
|
||||||
|
setScopeFilter(false);
|
||||||
|
setDraftScopeFilter(false);
|
||||||
|
setPage(1);
|
||||||
|
setSelected({});
|
||||||
|
}}
|
||||||
|
sort={{
|
||||||
|
value: sort,
|
||||||
|
direction,
|
||||||
|
options: [
|
||||||
|
{ value: "name", label: "集合名称" },
|
||||||
|
{ value: "created_at", label: "创建时间" },
|
||||||
|
{ value: "updated_at", label: "更新时间" },
|
||||||
|
],
|
||||||
|
onChange: (v, d) => {
|
||||||
|
setSort(v);
|
||||||
|
setDirection(d);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="刷新"
|
||||||
|
icon={<IconRefresh aria-hidden="true" />}
|
||||||
|
onClick={() => setRev((v) => v + 1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
|
theme="solid"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true);
|
||||||
|
setName("新数据准备集合");
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建集合
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
selection={
|
||||||
|
Object.keys(selected).length > 0 ? (
|
||||||
|
<>
|
||||||
|
<span>已选择 {Object.keys(selected).length} 个集合</span>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="删除所选"
|
||||||
|
icon={<IconDelete aria-hidden="true" />}
|
||||||
|
type="danger"
|
||||||
|
onClick={() => setDeleting(Object.values(selected))}
|
||||||
|
/>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="清空选择"
|
||||||
|
icon={<IconClose aria-hidden="true" />}
|
||||||
|
onClick={() => setSelected({})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Table<Preparation>
|
||||||
|
fill
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={data.items}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{
|
||||||
|
x: visibleColumns.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
|
||||||
|
y: "100%",
|
||||||
|
}}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: Object.keys(selected),
|
||||||
|
onChange: (keys) =>
|
||||||
|
setSelected((old) => {
|
||||||
|
const n: Record<string, Preparation> = {};
|
||||||
|
for (const k of keys ?? []) {
|
||||||
|
const r = data.items.find((x) => x.id === k) ?? old[k];
|
||||||
|
if (r) n[k] = r;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
columns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
<footer className="table-pagination workspace-table-footer">
|
<footer className="table-pagination workspace-table-footer">
|
||||||
<span>共 {data.total} 个集合</span>
|
<span>共 {data.total} 个集合</span>
|
||||||
@@ -268,7 +369,7 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
closeOnEsc
|
closeOnEsc
|
||||||
onCancel={() => setCreating(false)}
|
onCancel={() => setCreating(false)}
|
||||||
confirmLoading={busy}
|
confirmLoading={busy}
|
||||||
okButtonProps={{ disabled: !name.trim() || busy }}
|
okButtonProps={{ disabled: !name.trim() || busy || !scopeValid }}
|
||||||
onOk={() =>
|
onOk={() =>
|
||||||
work(async () => {
|
work(async () => {
|
||||||
const row = await post<Preparation>("/data-preparations", {
|
const row = await post<Preparation>("/data-preparations", {
|
||||||
@@ -283,7 +384,12 @@ export function DataPreparationPage({ active }: { active: boolean }) {
|
|||||||
>
|
>
|
||||||
{error && <Banner type="danger" description={error} />}
|
{error && <Banner type="danger" description={error} />}
|
||||||
<Input aria-label="集合名称" value={name} onChange={setName} />
|
<Input aria-label="集合名称" value={name} onChange={setName} />
|
||||||
<ScopeControls value={scope} onChange={setScope} />
|
<ScopeControls
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
active={creating && active}
|
||||||
|
onValidityChange={setScopeValid}
|
||||||
|
/>
|
||||||
<TextArea aria-label="集合备注" value={note} onChange={setNote} />
|
<TextArea aria-label="集合备注" value={note} onChange={setNote} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
CatalogTableToolbar,
|
||||||
|
CatalogIconAction,
|
||||||
|
useCatalogColumns,
|
||||||
|
} from "../components/CatalogTableToolbar";
|
||||||
|
import { IconPlus, IconRefresh, IconClose } from "@douyinfe/semi-icons";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -13,7 +19,7 @@ import { api, patch, post, queryString } from "../api";
|
|||||||
import { ResearchSelect } from "../research/ResearchSelect";
|
import { ResearchSelect } from "../research/ResearchSelect";
|
||||||
import { ScopeControls } from "./ScopeControls";
|
import { ScopeControls } from "./ScopeControls";
|
||||||
import { DataPreparationPicker } from "./DataPreparationPicker";
|
import { DataPreparationPicker } from "./DataPreparationPicker";
|
||||||
import { FieldTable } from "./FieldTable";
|
import { FieldTable, fieldColumnChoices } from "./FieldTable";
|
||||||
import {
|
import {
|
||||||
defaultScope,
|
defaultScope,
|
||||||
fieldReference,
|
fieldReference,
|
||||||
@@ -118,8 +124,13 @@ export function FieldBrowser({
|
|||||||
[queriedScope, setQueriedScope] = useState<string | null>(null);
|
[queriedScope, setQueriedScope] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1),
|
const [page, setPage] = useState(1),
|
||||||
[size, setSize] = useState(25),
|
[size, setSize] = useState(25),
|
||||||
[refresh, setRefresh] = useState(0),
|
[refresh, setRefresh] = useState(0);
|
||||||
[advanced, setAdvanced] = useState(false);
|
const [sort, setSort] = useState("id"),
|
||||||
|
[direction, setDirection] = useState("asc");
|
||||||
|
const columnSettings = useCatalogColumns(
|
||||||
|
`catalog-fields-${source}-columns`,
|
||||||
|
fieldColumnChoices,
|
||||||
|
);
|
||||||
const [data, setData] = useState<Page<FieldRecord>>({
|
const [data, setData] = useState<Page<FieldRecord>>({
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -160,7 +171,7 @@ export function FieldBrowser({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
api<Page<FieldRecord>>(
|
api<Page<FieldRecord>>(
|
||||||
`/catalog/${source === "local" ? "fields" : "worldquant/fields"}?${queryString({ ...actual, ...filters, limit: size, offset: (page - 1) * size })}`,
|
`/catalog/${source === "local" ? "fields" : "worldquant/fields"}?${queryString({ ...actual, ...filters, ...(source === "local" ? { sort, direction } : {}), limit: size, offset: (page - 1) * size })}`,
|
||||||
{ signal: c.signal },
|
{ signal: c.signal },
|
||||||
)
|
)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -181,7 +192,19 @@ export function FieldBrowser({
|
|||||||
if (!c.signal.aborted) setLoading(false);
|
if (!c.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => c.abort();
|
return () => c.abort();
|
||||||
}, [active, source, key, filters, page, size, refresh, revision, canRead]);
|
}, [
|
||||||
|
active,
|
||||||
|
source,
|
||||||
|
key,
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
refresh,
|
||||||
|
revision,
|
||||||
|
canRead,
|
||||||
|
]);
|
||||||
const update = (k: string, v: string) =>
|
const update = (k: string, v: string) =>
|
||||||
setDraft((old) => ({ ...old, [k]: v }));
|
setDraft((old) => ({ ...old, [k]: v }));
|
||||||
return (
|
return (
|
||||||
@@ -195,33 +218,13 @@ export function FieldBrowser({
|
|||||||
revision={revision}
|
revision={revision}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="preparation-tools">
|
<CatalogTableToolbar
|
||||||
<Input
|
label="字段目录"
|
||||||
aria-label="搜索字段"
|
active={active && !adding}
|
||||||
placeholder="字段 ID、名称或描述"
|
columns={columnSettings}
|
||||||
value={draft.q ?? ""}
|
filterCount={Object.values(filters).filter(Boolean).length}
|
||||||
onChange={(v) => update("q", v)}
|
canApply={source === "local" || (hasCriteria && !loading)}
|
||||||
/>
|
onApply={() => {
|
||||||
<Input
|
|
||||||
aria-label="字段数据集"
|
|
||||||
placeholder="数据集 ID"
|
|
||||||
value={draft.dataset_id ?? ""}
|
|
||||||
onChange={(v) => update("dataset_id", v)}
|
|
||||||
/>
|
|
||||||
<ResearchSelect
|
|
||||||
label="字段类型"
|
|
||||||
placeholder="全部类型"
|
|
||||||
showClear
|
|
||||||
value={draft.field_type || undefined}
|
|
||||||
optionList={["MATRIX", "VECTOR", "GROUP"].map((v) => ({
|
|
||||||
label: v,
|
|
||||||
value: v,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => update("field_type", String(v ?? ""))}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={source === "worldquant" && (!hasCriteria || loading)}
|
|
||||||
onClick={() => {
|
|
||||||
setQueriedScope(key);
|
setQueriedScope(key);
|
||||||
setFilters({
|
setFilters({
|
||||||
...draft,
|
...draft,
|
||||||
@@ -234,13 +237,9 @@ export function FieldBrowser({
|
|||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setRefresh((v) => v + 1);
|
setRefresh((v) => v + 1);
|
||||||
|
setSelected({});
|
||||||
}}
|
}}
|
||||||
>
|
onReset={() => {
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setAdvanced((v) => !v)}>更多筛选</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setDraft({});
|
setDraft({});
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setQueriedScope(null);
|
setQueriedScope(null);
|
||||||
@@ -251,12 +250,46 @@ export function FieldBrowser({
|
|||||||
setData({ items: [], total: 0, limit: size, offset: 0 });
|
setData({ items: [], total: 0, limit: size, offset: 0 });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
filters={
|
||||||
重置
|
<>
|
||||||
</Button>
|
<div className="catalog-filter-grid">
|
||||||
|
<label className="catalog-filter-wide">
|
||||||
|
搜索
|
||||||
|
<Input
|
||||||
|
aria-label="搜索字段"
|
||||||
|
placeholder="字段 ID、名称或描述"
|
||||||
|
value={draft.q ?? ""}
|
||||||
|
onChange={(v) => update("q", v)}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
数据集
|
||||||
|
<Input
|
||||||
|
aria-label="字段数据集"
|
||||||
|
placeholder="数据集 ID"
|
||||||
|
value={draft.dataset_id ?? ""}
|
||||||
|
onChange={(v) => update("dataset_id", v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
类型
|
||||||
|
<ResearchSelect
|
||||||
|
label="字段类型"
|
||||||
|
placeholder="全部类型"
|
||||||
|
showClear
|
||||||
|
value={draft.field_type || undefined}
|
||||||
|
optionList={["MATRIX", "VECTOR", "GROUP"].map((v) => ({
|
||||||
|
label: v,
|
||||||
|
value: v,
|
||||||
|
}))}
|
||||||
|
onChange={(v) => update("field_type", String(v ?? ""))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{advanced && (
|
<details>
|
||||||
<div className="preparation-filters">
|
<summary>更多筛选</summary>
|
||||||
|
<div className="catalog-filter-grid">
|
||||||
{source === "local" &&
|
{source === "local" &&
|
||||||
["category", "subcategory"].map((k, i) => (
|
["category", "subcategory"].map((k, i) => (
|
||||||
<label key={k}>
|
<label key={k}>
|
||||||
@@ -297,6 +330,7 @@ export function FieldBrowser({
|
|||||||
<label>
|
<label>
|
||||||
同步起始时间
|
同步起始时间
|
||||||
<input
|
<input
|
||||||
|
aria-label="同步起始时间"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draft.synced_from ?? ""}
|
value={draft.synced_from ?? ""}
|
||||||
onChange={(e) => update("synced_from", e.target.value)}
|
onChange={(e) => update("synced_from", e.target.value)}
|
||||||
@@ -305,47 +339,51 @@ export function FieldBrowser({
|
|||||||
<label>
|
<label>
|
||||||
同步结束时间
|
同步结束时间
|
||||||
<input
|
<input
|
||||||
|
aria-label="同步结束时间"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draft.synced_to ?? ""}
|
value={draft.synced_to ?? ""}
|
||||||
onChange={(e) => update("synced_to", e.target.value)}
|
onChange={(e) => update("synced_to", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<ResearchSelect
|
|
||||||
label="字段排序"
|
|
||||||
value={draft.sort ?? "id"}
|
|
||||||
optionList={[
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"dataset_id",
|
|
||||||
"coverage",
|
|
||||||
"user_count",
|
|
||||||
"alpha_count",
|
|
||||||
"synced_at",
|
|
||||||
].map((v) => ({ value: v, label: v }))}
|
|
||||||
onChange={(v) => update("sort", String(v))}
|
|
||||||
/>
|
|
||||||
<ResearchSelect
|
|
||||||
label="排序方向"
|
|
||||||
value={draft.direction ?? "asc"}
|
|
||||||
optionList={[
|
|
||||||
{ value: "asc", label: "升序" },
|
|
||||||
{ value: "desc", label: "降序" },
|
|
||||||
]}
|
|
||||||
onChange={(v) => update("direction", String(v))}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</details>
|
||||||
{error && <Banner type="danger" description={error} />}
|
</>
|
||||||
{source === "worldquant" && !canRead && (
|
}
|
||||||
<p className="preparation-query-hint">
|
sort={
|
||||||
请输入关键词、数据集或其他筛选条件,然后点击查询。
|
source === "local"
|
||||||
</p>
|
? {
|
||||||
)}
|
value: sort,
|
||||||
<div className="preparation-tools">
|
direction,
|
||||||
|
options: [
|
||||||
|
{ value: "id", label: "字段 ID" },
|
||||||
|
{ value: "name", label: "名称" },
|
||||||
|
{ value: "dataset_id", label: "数据集" },
|
||||||
|
{ value: "coverage", label: "覆盖率" },
|
||||||
|
{ value: "user_count", label: "用户数" },
|
||||||
|
{ value: "alpha_count", label: "Alpha 数" },
|
||||||
|
{ value: "synced_at", label: "同步时间" },
|
||||||
|
],
|
||||||
|
onChange: (value, dir) => {
|
||||||
|
setSort(value);
|
||||||
|
setDirection(dir);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="刷新字段"
|
||||||
|
icon={<IconRefresh aria-hidden="true" />}
|
||||||
|
disabled={!canRead || loading}
|
||||||
|
onClick={() => setRefresh((v) => v + 1)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
|
icon={<IconPlus aria-hidden="true" />}
|
||||||
disabled={!Object.keys(selected).length || loading || !!error}
|
disabled={!Object.keys(selected).length || loading || !!error}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAdd ? onAdd(Object.values(selected)) : setAdding(true)
|
onAdd ? onAdd(Object.values(selected)) : setAdding(true)
|
||||||
@@ -353,12 +391,31 @@ export function FieldBrowser({
|
|||||||
>
|
>
|
||||||
加入数据准备
|
加入数据准备
|
||||||
</Button>
|
</Button>
|
||||||
<span>已选 {Object.keys(selected).length} 个字段 · 表头选择当前页</span>
|
</>
|
||||||
{!!Object.keys(selected).length && (
|
}
|
||||||
<Button onClick={() => setSelected({})}>清空选择</Button>
|
selection={
|
||||||
|
Object.keys(selected).length ? (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
已选 {Object.keys(selected).length} 个字段 · 表头选择当前页
|
||||||
|
</span>
|
||||||
|
<CatalogIconAction
|
||||||
|
label="清空选择"
|
||||||
|
icon={<IconClose aria-hidden="true" />}
|
||||||
|
onClick={() => setSelected({})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
{source === "worldquant" && !canRead && (
|
||||||
|
<p className="preparation-query-hint">
|
||||||
|
请输入关键词、数据集或其他筛选条件,然后点击查询。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<FieldTable
|
<FieldTable
|
||||||
|
visibleColumns={columnSettings.visible}
|
||||||
fill={!fixedScope}
|
fill={!fixedScope}
|
||||||
items={data.items}
|
items={data.items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Button, Tag } from "@douyinfe/semi-ui-19";
|
|||||||
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
import { displayValue, formatTime } from "../api";
|
import { displayValue, formatTime } from "../api";
|
||||||
import type { FieldRecord } from "./types";
|
import type { FieldRecord } from "./types";
|
||||||
|
export const fieldColumnChoices = [
|
||||||
|
{ key: "id", label: "字段", required: true },
|
||||||
|
{ key: "dataset_id", label: "数据集" },
|
||||||
|
{ key: "description", label: "字段描述" },
|
||||||
|
{ key: "field_type", label: "类型" },
|
||||||
|
{ key: "coverage", label: "覆盖率" },
|
||||||
|
{ key: "user_count", label: "用户数" },
|
||||||
|
{ key: "alpha_count", label: "Alpha 数" },
|
||||||
|
{ key: "synced_at", label: "同步时间" },
|
||||||
|
];
|
||||||
export function FieldTable({
|
export function FieldTable({
|
||||||
items,
|
items,
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -10,10 +20,12 @@ export function FieldTable({
|
|||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
visibleColumns,
|
||||||
}: {
|
}: {
|
||||||
items: FieldRecord[];
|
items: FieldRecord[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
visibleColumns?: string[];
|
||||||
onOpen?: (field: FieldRecord) => void;
|
onOpen?: (field: FieldRecord) => void;
|
||||||
selected?: string[];
|
selected?: string[];
|
||||||
onSelect?: (ids: string[]) => void;
|
onSelect?: (ids: string[]) => void;
|
||||||
@@ -78,6 +90,10 @@ export function FieldTable({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const shown = columns.filter(
|
||||||
|
(_, index) =>
|
||||||
|
!visibleColumns || visibleColumns.includes(fieldColumnChoices[index].key),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Table<FieldRecord>
|
<Table<FieldRecord>
|
||||||
fill={fill}
|
fill={fill}
|
||||||
@@ -85,10 +101,13 @@ export function FieldTable({
|
|||||||
className="preparation-table"
|
className="preparation-table"
|
||||||
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
|
rowKey={(r) => `${r?.dataset_id}|${r?.id}`}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
columns={columns}
|
columns={shown}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1420, y: fill ? "100%" : 400 }}
|
scroll={{
|
||||||
|
x: shown.reduce((sum, c) => sum + Number(c.width ?? 140), 0),
|
||||||
|
y: fill ? "100%" : 400,
|
||||||
|
}}
|
||||||
rowSelection={
|
rowSelection={
|
||||||
onSelect
|
onSelect
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,132 +1,2 @@
|
|||||||
import { useEffect, useState } from "react";
|
// Keep directory callers on one implementation while preserving their source/revision contract.
|
||||||
import { Banner, Button } from "@douyinfe/semi-ui-19";
|
export { ScopePicker as ScopeControls } from "../settings/ScopePicker";
|
||||||
import { api } from "../api";
|
|
||||||
import { ResearchSelect } from "../research/ResearchSelect";
|
|
||||||
import { type Scope } from "./types";
|
|
||||||
type Option = Omit<Scope, "universe"> & { universes: string[] };
|
|
||||||
export function ScopeControls({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
local = false,
|
|
||||||
active = true,
|
|
||||||
revision: sourceRevision = "",
|
|
||||||
}: {
|
|
||||||
value: Scope;
|
|
||||||
onChange: (s: Scope) => void;
|
|
||||||
local?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
revision?: string;
|
|
||||||
}) {
|
|
||||||
const [rows, setRows] = useState<Option[]>([]),
|
|
||||||
[error, setError] = useState(""),
|
|
||||||
[revision, setRevision] = useState(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
const c = new AbortController();
|
|
||||||
api<{ instrument_options: Option[] }>(
|
|
||||||
local ? "/catalog/local-scopes" : "/catalog/scopes",
|
|
||||||
{ signal: c.signal },
|
|
||||||
)
|
|
||||||
.then((r) => {
|
|
||||||
setRows(r.instrument_options);
|
|
||||||
setError("");
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (!c.signal.aborted) setError(e.message);
|
|
||||||
});
|
|
||||||
return () => c.abort();
|
|
||||||
}, [local, revision, sourceRevision, active]);
|
|
||||||
function update(next: Scope) {
|
|
||||||
const row =
|
|
||||||
rows.find(
|
|
||||||
(r) =>
|
|
||||||
r.instrument_type === next.instrument_type &&
|
|
||||||
r.region === next.region &&
|
|
||||||
r.delay === next.delay,
|
|
||||||
) ??
|
|
||||||
rows.find(
|
|
||||||
(r) =>
|
|
||||||
r.instrument_type === next.instrument_type &&
|
|
||||||
r.region === next.region,
|
|
||||||
) ??
|
|
||||||
rows.find((r) => r.instrument_type === next.instrument_type) ??
|
|
||||||
rows[0];
|
|
||||||
onChange(
|
|
||||||
row
|
|
||||||
? {
|
|
||||||
...next,
|
|
||||||
instrument_type: row.instrument_type,
|
|
||||||
region: row.region,
|
|
||||||
delay: row.delay,
|
|
||||||
universe: row.universes.includes(next.universe)
|
|
||||||
? next.universe
|
|
||||||
: row.universes[0],
|
|
||||||
}
|
|
||||||
: next,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const option = rows.find(
|
|
||||||
(r) =>
|
|
||||||
r.instrument_type === value.instrument_type &&
|
|
||||||
r.region === value.region &&
|
|
||||||
r.delay === value.delay,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="preparation-tools">
|
|
||||||
{(["instrument_type", "region", "universe", "delay"] as const).map(
|
|
||||||
(key) => {
|
|
||||||
const values =
|
|
||||||
key === "universe"
|
|
||||||
? (option?.universes ?? [value.universe])
|
|
||||||
: rows
|
|
||||||
.filter(
|
|
||||||
(r) =>
|
|
||||||
key === "instrument_type" ||
|
|
||||||
(r.instrument_type === value.instrument_type &&
|
|
||||||
(key !== "delay" || r.region === value.region)),
|
|
||||||
)
|
|
||||||
.map((r) => r[key]);
|
|
||||||
return (
|
|
||||||
<ResearchSelect
|
|
||||||
key={key}
|
|
||||||
label={
|
|
||||||
{
|
|
||||||
instrument_type: "InstrumentType",
|
|
||||||
region: "Region",
|
|
||||||
universe: "Universe",
|
|
||||||
delay: "Delay",
|
|
||||||
}[key]
|
|
||||||
}
|
|
||||||
value={value[key]}
|
|
||||||
optionList={[...new Set([value[key], ...values])].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: key === "delay" ? `Delay ${v}` : String(v),
|
|
||||||
}))}
|
|
||||||
onChange={(v) =>
|
|
||||||
update({
|
|
||||||
...value,
|
|
||||||
[key]: key === "delay" ? Number(v) : String(v),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<Banner
|
|
||||||
type="warning"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{error}{" "}
|
|
||||||
<Button onClick={() => setRevision((v) => v + 1)}>
|
|
||||||
重试范围选项
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
||||||
|
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||||
|
import { initialSettings } from "../backtests/types";
|
||||||
import {
|
import {
|
||||||
ResearchDataInput,
|
ResearchDataInput,
|
||||||
researchSelection,
|
researchSelection,
|
||||||
@@ -40,21 +43,9 @@ export function FlowLaunchForm({
|
|||||||
const [seed, setSeed] = useState(0);
|
const [seed, setSeed] = useState(0);
|
||||||
const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null);
|
const [confirmation, setConfirmation] = useState<FlowLaunch | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [settings, setSettings] = useState<SimulationSettings>({
|
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
||||||
instrumentType: "EQUITY",
|
const options = useSettingsOptions();
|
||||||
region: "USA",
|
const [settingsValid, setSettingsValid] = useState(false);
|
||||||
universe: "TOP3000",
|
|
||||||
delay: 1,
|
|
||||||
decay: 0,
|
|
||||||
neutralization: "INDUSTRY",
|
|
||||||
truncation: 0.08,
|
|
||||||
pasteurization: "ON",
|
|
||||||
unitHandling: "VERIFY",
|
|
||||||
nanHandling: "OFF",
|
|
||||||
language: "FASTEXPR",
|
|
||||||
visualization: false,
|
|
||||||
maxTrade: "OFF",
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = new AbortController();
|
const c = new AbortController();
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -169,47 +160,17 @@ export function FlowLaunchForm({
|
|||||||
onChange={(v) => setTemplate(v ? String(v) : undefined)}
|
onChange={(v) => setTemplate(v ? String(v) : undefined)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p>
|
<SimulationSettingsEditor
|
||||||
{settings.region} / {settings.universe} / D{settings.delay} · REGULAR /
|
value={settings}
|
||||||
FASTEXPR / EQUITY
|
onChange={setSettings}
|
||||||
</p>
|
{...options}
|
||||||
<div className="research-form-grid">
|
onRefresh={() => void options.refresh()}
|
||||||
<label>
|
fixedScopes={inputs
|
||||||
中性化
|
.filter((r) => ids.includes(r.id))
|
||||||
<Input
|
.map((r) => r.scope)}
|
||||||
aria-label="自动研究中性化"
|
disabled={busy}
|
||||||
value={settings.neutralization}
|
onValidityChange={setSettingsValid}
|
||||||
onChange={(neutralization) =>
|
|
||||||
setSettings({ ...settings, neutralization })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Decay
|
|
||||||
<InputNumber
|
|
||||||
aria-label="自动研究 Decay"
|
|
||||||
min={0}
|
|
||||||
value={settings.decay}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (typeof v === "number") setSettings({ ...settings, decay: v });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Truncation
|
|
||||||
<InputNumber
|
|
||||||
aria-label="自动研究 Truncation"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={settings.truncation}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (typeof v === "number")
|
|
||||||
setSettings({ ...settings, truncation: v });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<h4>本次授权预算</h4>
|
<h4>本次授权预算</h4>
|
||||||
<div className="research-form-grid">
|
<div className="research-form-grid">
|
||||||
{(
|
{(
|
||||||
@@ -263,7 +224,13 @@ export function FlowLaunchForm({
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
disabled={!ids.length || !hypothesis.trim() || !name.trim()}
|
disabled={
|
||||||
|
!ids.length ||
|
||||||
|
!hypothesis.trim() ||
|
||||||
|
!name.trim() ||
|
||||||
|
!settingsValid ||
|
||||||
|
busy
|
||||||
|
}
|
||||||
onClick={preview}
|
onClick={preview}
|
||||||
>
|
>
|
||||||
核对并启动研究
|
核对并启动研究
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor";
|
||||||
|
import { useSettingsOptions } from "../settings/useSettingsOptions";
|
||||||
import {
|
import {
|
||||||
ResearchDataInput,
|
ResearchDataInput,
|
||||||
researchSelection,
|
researchSelection,
|
||||||
@@ -67,7 +69,8 @@ export function ResearchWorkspace({
|
|||||||
const [parent, setParent] = useState("");
|
const [parent, setParent] = useState("");
|
||||||
const [method, setMethod] = useState("structure");
|
const [method, setMethod] = useState("structure");
|
||||||
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
const [settings, setSettings] = useState<SimulationSettings>(initialSettings);
|
||||||
const [neutralizations, setNeutralizations] = useState<string[]>([]);
|
const options = useSettingsOptions(active && detailOpen);
|
||||||
|
const [settingsValid, setSettingsValid] = useState(false);
|
||||||
const [mode, setMode] = useState("all");
|
const [mode, setMode] = useState("all");
|
||||||
const [limit, setLimit] = useState(100);
|
const [limit, setLimit] = useState(100);
|
||||||
const [seed, setSeed] = useState(0);
|
const [seed, setSeed] = useState(0);
|
||||||
@@ -188,28 +191,6 @@ export function ResearchWorkspace({
|
|||||||
setMethod("structure");
|
setMethod("structure");
|
||||||
}
|
}
|
||||||
}, [active, action]);
|
}, [active, action]);
|
||||||
async function refreshSettings() {
|
|
||||||
const data = await post<{
|
|
||||||
content: {
|
|
||||||
items: {
|
|
||||||
region: string;
|
|
||||||
universe: string;
|
|
||||||
delay: number;
|
|
||||||
neutralizations: string[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
}>("/catalog/setting-options/refresh");
|
|
||||||
const options = data.content.items
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.region === settings.region &&
|
|
||||||
row.universe === settings.universe &&
|
|
||||||
row.delay === settings.delay,
|
|
||||||
)
|
|
||||||
.flatMap((row) => row.neutralizations);
|
|
||||||
setNeutralizations([...new Set(options)]);
|
|
||||||
Toast.success("合法设置已同步");
|
|
||||||
}
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const next = asset
|
const next = asset
|
||||||
? await api<Asset>(`/research/assets/${asset.id}`, {
|
? await api<Asset>(`/research/assets/${asset.id}`, {
|
||||||
@@ -759,88 +740,24 @@ export function ResearchWorkspace({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="research-section-heading">
|
<SimulationSettingsEditor
|
||||||
<h4>模拟设置</h4>
|
value={settings}
|
||||||
<Button
|
onChange={setSettings}
|
||||||
disabled={!inputIds.length || !!busy}
|
{...options}
|
||||||
onClick={() => void task("同步合法设置", refreshSettings)}
|
onRefresh={() => void options.refresh()}
|
||||||
>
|
fixedScopes={selectedInputs.map((r) => r.scope)}
|
||||||
同步合法设置
|
disabled={!!busy}
|
||||||
</Button>
|
onValidityChange={setSettingsValid}
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{settings.region || "未选择市场"} /{" "}
|
|
||||||
{settings.universe || "未选择股票池"} / D{settings.delay}
|
|
||||||
(来自固定输入)
|
|
||||||
</p>
|
|
||||||
<div className="research-form-grid">
|
|
||||||
<label>
|
|
||||||
中性化
|
|
||||||
<Input
|
|
||||||
list={`${page}-neutralizations`}
|
|
||||||
aria-label="中性化"
|
|
||||||
value={settings.neutralization}
|
|
||||||
onChange={(neutralization) =>
|
|
||||||
setSettings({ ...settings, neutralization })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<datalist id={`${page}-neutralizations`}>
|
|
||||||
{neutralizations.map((value) => (
|
|
||||||
<option key={value} value={value} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Decay
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Decay"
|
|
||||||
min={0}
|
|
||||||
value={settings.decay}
|
|
||||||
onChange={(decay) =>
|
|
||||||
setSettings({ ...settings, decay: Number(decay) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Truncation
|
|
||||||
<InputNumber
|
|
||||||
aria-label="Truncation"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={settings.truncation}
|
|
||||||
onChange={(truncation) =>
|
|
||||||
setSettings({
|
|
||||||
...settings,
|
|
||||||
truncation: Number(truncation),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="research-form-grid">
|
|
||||||
{(["pasteurization", "nanHandling", "maxTrade"] as const).map(
|
|
||||||
(key) => (
|
|
||||||
<label key={key}>
|
|
||||||
{key}
|
|
||||||
<ResearchSelect
|
|
||||||
label={key}
|
|
||||||
value={settings[key]}
|
|
||||||
optionList={["ON", "OFF"].map((value) => ({
|
|
||||||
value,
|
|
||||||
label: value,
|
|
||||||
}))}
|
|
||||||
onChange={(value) =>
|
|
||||||
setSettings({ ...settings, [key]: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
disabled={dirty || !inputIds.length || !hypothesis || !!busy}
|
disabled={
|
||||||
|
dirty ||
|
||||||
|
!inputIds.length ||
|
||||||
|
!hypothesis ||
|
||||||
|
!!busy ||
|
||||||
|
!settingsValid
|
||||||
|
}
|
||||||
onClick={() => void task("生成候选", expand)}
|
onClick={() => void task("生成候选", expand)}
|
||||||
>
|
>
|
||||||
保存候选研究记录
|
保存候选研究记录
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
import { Banner, Button } from "@douyinfe/semi-ui-19";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { ResearchSelect } from "../research/ResearchSelect";
|
||||||
|
import type { Scope } from "../preparations/types";
|
||||||
|
import {
|
||||||
|
changeScope,
|
||||||
|
scopeFields,
|
||||||
|
scopeLabels,
|
||||||
|
scopeValues,
|
||||||
|
validScope,
|
||||||
|
type ScopeOption,
|
||||||
|
} from "./model";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
/** Edit a scope transactionally. Supplied options bypass network reads (fixed simulation metadata). */
|
||||||
|
export function ScopePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
local = false,
|
||||||
|
active = true,
|
||||||
|
revision = "",
|
||||||
|
options,
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
|
onValidityChange,
|
||||||
|
}: {
|
||||||
|
value: Scope;
|
||||||
|
onChange: (scope: Scope) => void;
|
||||||
|
local?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
revision?: string;
|
||||||
|
options?: ScopeOption[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<Partial<Scope>>(value);
|
||||||
|
const [rows, setRows] = useState<ScopeOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
const id = useId();
|
||||||
|
const choices = options ?? rows;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || options || readOnly) return;
|
||||||
|
const c = new AbortController();
|
||||||
|
setRows([]);
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
api<{ instrument_options: ScopeOption[] }>(
|
||||||
|
local ? "/catalog/local-scopes" : "/catalog/scopes",
|
||||||
|
{ signal: c.signal },
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
if (!c.signal.aborted) setRows(result.instrument_options);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!c.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!c.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => c.abort();
|
||||||
|
}, [active, local, revision, retry, options, readOnly]);
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [
|
||||||
|
active,
|
||||||
|
local,
|
||||||
|
revision,
|
||||||
|
readOnly,
|
||||||
|
value.instrument_type,
|
||||||
|
value.region,
|
||||||
|
value.delay,
|
||||||
|
value.universe,
|
||||||
|
]);
|
||||||
|
const valid = validScope(choices, value) && !loading && !error;
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange?.(valid && !open);
|
||||||
|
}, [valid, open, onValidityChange]);
|
||||||
|
const complete =
|
||||||
|
value.instrument_type &&
|
||||||
|
value.region &&
|
||||||
|
value.universe &&
|
||||||
|
Number.isFinite(value.delay);
|
||||||
|
return (
|
||||||
|
<section className="scope-picker" aria-label="组合选择">
|
||||||
|
<div className="scope-picker-summary">
|
||||||
|
<div>
|
||||||
|
<span className="settings-label">组合</span>
|
||||||
|
<span>
|
||||||
|
{complete
|
||||||
|
? `${value.instrument_type} / ${value.region} / ${value.universe} / Delay ${value.delay}`
|
||||||
|
: "请选择组合"}
|
||||||
|
</span>
|
||||||
|
{readOnly && <small>来自固定数据准备</small>}
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
disabled={disabled || !active}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(value);
|
||||||
|
setOpen(!open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{complete ? "更改组合" : "选择组合"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!readOnly &&
|
||||||
|
!options &&
|
||||||
|
!open &&
|
||||||
|
(loading ? (
|
||||||
|
<small className="settings-hint">正在加载组合选项…</small>
|
||||||
|
) : error ? (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{error}{" "}
|
||||||
|
<Button onClick={() => setRetry((n) => n + 1)}>
|
||||||
|
重试范围选项
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : !choices.length ? (
|
||||||
|
<small className="settings-hint">
|
||||||
|
暂无可用组合
|
||||||
|
{local ? ",请先同步本地数据" : ",请检查平台连接或重试范围选项"}
|
||||||
|
</small>
|
||||||
|
) : !valid ? (
|
||||||
|
<small className="settings-error">当前组合不可用,请重新选择</small>
|
||||||
|
) : null)}
|
||||||
|
{open && (
|
||||||
|
<div id={id} className="scope-picker-panel">
|
||||||
|
<div className="settings-grid">
|
||||||
|
{scopeFields.map((key) => {
|
||||||
|
const values = scopeValues(choices, draft, key);
|
||||||
|
const current = draft[key];
|
||||||
|
const invalid =
|
||||||
|
current !== undefined &&
|
||||||
|
current !== "" &&
|
||||||
|
!values.includes(current);
|
||||||
|
return (
|
||||||
|
<div className="settings-field" key={key}>
|
||||||
|
<span>{scopeLabels[key]}</span>
|
||||||
|
<ResearchSelect
|
||||||
|
label={scopeLabels[key]}
|
||||||
|
value={current}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled || loading || !values.length}
|
||||||
|
filter={values.length > 6}
|
||||||
|
placeholder={`请选择 ${scopeLabels[key]}`}
|
||||||
|
validateStatus={invalid ? "error" : "default"}
|
||||||
|
optionList={values.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: key === "delay" ? `Delay ${v}` : String(v),
|
||||||
|
}))}
|
||||||
|
onChange={(v) =>
|
||||||
|
setDraft(
|
||||||
|
changeScope(
|
||||||
|
choices,
|
||||||
|
draft,
|
||||||
|
key,
|
||||||
|
key === "delay" ? Number(v) : String(v),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{invalid && (
|
||||||
|
<small className="settings-error">
|
||||||
|
原值 {String(current)} 不可用,请重新选择
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<Banner type="warning" description={error} />
|
||||||
|
) : !loading && !choices.length ? (
|
||||||
|
<p>
|
||||||
|
暂无可用组合
|
||||||
|
{local ? ",请先同步本地数据" : ",请同步选项或检查平台连接"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="settings-hint">
|
||||||
|
更改上级选项后,请补齐失效的下级选项。应用后生效。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="settings-actions">
|
||||||
|
{!options && (
|
||||||
|
<Button loading={loading} onClick={() => setRetry((n) => n + 1)}>
|
||||||
|
重试范围选项
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
disabled={
|
||||||
|
disabled || loading || !!error || !validScope(choices, draft)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (validScope(choices, draft)) {
|
||||||
|
onChange(draft);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用组合
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Banner, Button, InputNumber } from "@douyinfe/semi-ui-19";
|
||||||
|
import { formatTime } from "../api";
|
||||||
|
import type { SimulationSettings } from "../backtests/types";
|
||||||
|
import type { Scope } from "../preparations/types";
|
||||||
|
import { ResearchSelect } from "../research/ResearchSelect";
|
||||||
|
import { ScopePicker } from "./ScopePicker";
|
||||||
|
import {
|
||||||
|
asScopeOptions,
|
||||||
|
fieldOptions,
|
||||||
|
parameterLabels,
|
||||||
|
sameScope,
|
||||||
|
settingsErrors,
|
||||||
|
settingsScope,
|
||||||
|
type SettingsRow,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
/** One typed settings value for all producers; fixed inputs constrain the scope, never the numeric parameters. */
|
||||||
|
export function SimulationSettingsEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchedAt,
|
||||||
|
onRefresh,
|
||||||
|
fixedScopes,
|
||||||
|
disabled = false,
|
||||||
|
onValidityChange,
|
||||||
|
}: {
|
||||||
|
value: SimulationSettings;
|
||||||
|
onChange: (value: SimulationSettings) => void;
|
||||||
|
rows: SettingsRow[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
fetchedAt?: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
fixedScopes?: Scope[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onValidityChange?: (valid: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const scope = settingsScope(value);
|
||||||
|
const row = rows.find((r) => sameScope(r, scope));
|
||||||
|
const errors = settingsErrors(value, rows, fixedScopes);
|
||||||
|
const [scopeReady, setScopeReady] = useState(false);
|
||||||
|
const valid =
|
||||||
|
!loading &&
|
||||||
|
!error &&
|
||||||
|
!errors.length &&
|
||||||
|
(fixedScopes !== undefined || scopeReady);
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange?.(valid);
|
||||||
|
}, [valid, onValidityChange]);
|
||||||
|
function field(key: string) {
|
||||||
|
const options = fieldOptions(row, key);
|
||||||
|
const current =
|
||||||
|
value[key as keyof SimulationSettings] ??
|
||||||
|
(key === "maxPosition" ? "OFF" : undefined);
|
||||||
|
const message = errors.find((e) =>
|
||||||
|
e.startsWith(parameterLabels[key] + " "),
|
||||||
|
);
|
||||||
|
const label = parameterLabels[key];
|
||||||
|
return (
|
||||||
|
<div className="settings-field" key={key}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{options.choices ? (
|
||||||
|
<ResearchSelect
|
||||||
|
label={label}
|
||||||
|
value={typeof current === "boolean" ? String(current) : current}
|
||||||
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
loading ||
|
||||||
|
!options.choices.length ||
|
||||||
|
(options.choices.length === 1 && !message)
|
||||||
|
}
|
||||||
|
filter={options.choices.length > 6}
|
||||||
|
placeholder={`请选择 ${label}`}
|
||||||
|
validateStatus={message ? "error" : "default"}
|
||||||
|
optionList={options.choices.map((v) => ({
|
||||||
|
value: typeof v === "boolean" ? String(v) : v,
|
||||||
|
label: typeof v === "boolean" ? (v ? "是" : "否") : String(v),
|
||||||
|
}))}
|
||||||
|
onChange={(next) =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
[key]: key === "visualization" ? next === "true" : next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InputNumber
|
||||||
|
aria-label={label}
|
||||||
|
value={
|
||||||
|
typeof current === "number" && Number.isFinite(current)
|
||||||
|
? current
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
min={options.minimum}
|
||||||
|
max={options.maximum}
|
||||||
|
precision={key === "decay" ? 0 : undefined}
|
||||||
|
step={key === "decay" ? 1 : 0.01}
|
||||||
|
validateStatus={message ? "error" : "default"}
|
||||||
|
onChange={(next) =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
[key]: typeof next === "number" ? next : NaN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{message && <small className="settings-error">{message}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="simulation-settings" aria-label="回测参数">
|
||||||
|
<div className="simulation-settings-header">
|
||||||
|
<strong>回测参数</strong>
|
||||||
|
<Button disabled={disabled} loading={loading} onClick={onRefresh}>
|
||||||
|
同步合法设置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<small className="settings-hint">
|
||||||
|
选项更新时间:{formatTime(fetchedAt ?? null)}
|
||||||
|
</small>
|
||||||
|
{error && (
|
||||||
|
<Banner type="warning" description={`${error};请重试同步合法设置`} />
|
||||||
|
)}
|
||||||
|
{!loading && !rows.length && !error && (
|
||||||
|
<Banner type="info" description="尚无参数选项,请先同步合法设置。" />
|
||||||
|
)}
|
||||||
|
<ScopePicker
|
||||||
|
value={scope}
|
||||||
|
options={asScopeOptions(rows)}
|
||||||
|
readOnly={fixedScopes !== undefined}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onValidityChange={setScopeReady}
|
||||||
|
onChange={(next) =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
instrumentType: next.instrument_type as "EQUITY",
|
||||||
|
region: next.region,
|
||||||
|
universe: next.universe,
|
||||||
|
delay: next.delay as 0 | 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!Object.values(parameterLabels).some((label) =>
|
||||||
|
e.startsWith(label + " "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((e) => (
|
||||||
|
<small className="settings-error" key={e}>
|
||||||
|
{e}
|
||||||
|
</small>
|
||||||
|
))}
|
||||||
|
<div className="settings-grid">
|
||||||
|
{["neutralization", "decay", "truncation"].map(field)}
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>更多参数</summary>
|
||||||
|
<div className="settings-grid">
|
||||||
|
{[
|
||||||
|
"pasteurization",
|
||||||
|
"nanHandling",
|
||||||
|
"maxTrade",
|
||||||
|
"maxPosition",
|
||||||
|
"unitHandling",
|
||||||
|
"language",
|
||||||
|
"visualization",
|
||||||
|
].map(field)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{errors.some((e) =>
|
||||||
|
[
|
||||||
|
"Pasteurization",
|
||||||
|
"NaN Handling",
|
||||||
|
"Max Trade",
|
||||||
|
"Max Position",
|
||||||
|
"Unit Handling",
|
||||||
|
"Language",
|
||||||
|
"Visualization",
|
||||||
|
].some((label) => e.startsWith(label + " ")),
|
||||||
|
) && (
|
||||||
|
<small className="settings-error">
|
||||||
|
更多参数中存在无效值,请展开检查。
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { SimulationSettings } from "../backtests/types";
|
||||||
|
import type { Scope } from "../preparations/types";
|
||||||
|
|
||||||
|
export type ScopeOption = Omit<Scope, "universe"> & { universes: string[] };
|
||||||
|
export type FieldOption = {
|
||||||
|
choices?: (string | number | boolean)[];
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
};
|
||||||
|
export type SettingsRow = Scope & {
|
||||||
|
neutralizations: string[];
|
||||||
|
fields?: Record<string, FieldOption>;
|
||||||
|
};
|
||||||
|
export type SettingsSnapshot = {
|
||||||
|
content: { items?: SettingsRow[] };
|
||||||
|
fetched_at: string | null;
|
||||||
|
};
|
||||||
|
export const scopeFields = [
|
||||||
|
"instrument_type",
|
||||||
|
"region",
|
||||||
|
"delay",
|
||||||
|
"universe",
|
||||||
|
] as const;
|
||||||
|
export const scopeLabels = {
|
||||||
|
instrument_type: "InstrumentType",
|
||||||
|
region: "Region",
|
||||||
|
delay: "Delay",
|
||||||
|
universe: "Universe",
|
||||||
|
};
|
||||||
|
export const settingsScope = (s: SimulationSettings): Scope => ({
|
||||||
|
instrument_type: s.instrumentType,
|
||||||
|
region: s.region,
|
||||||
|
delay: s.delay,
|
||||||
|
universe: s.universe,
|
||||||
|
});
|
||||||
|
export const sameScope = (a: Scope, b: Scope) =>
|
||||||
|
scopeFields.every((k) => a[k] === b[k]);
|
||||||
|
export function scopeValues(
|
||||||
|
rows: ScopeOption[],
|
||||||
|
value: Partial<Scope>,
|
||||||
|
key: keyof Scope,
|
||||||
|
): (string | number)[] {
|
||||||
|
const preceding = scopeFields.slice(0, scopeFields.indexOf(key));
|
||||||
|
const filtered = rows.filter((row) =>
|
||||||
|
preceding.every((k) => k === "universe" || row[k] === value[k]),
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
filtered.flatMap((row) =>
|
||||||
|
key === "universe" ? row.universes : [row[key]],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
/** Preserve valid descendants; only auto-select a descendant when its choice is unique. */
|
||||||
|
export function changeScope(
|
||||||
|
rows: ScopeOption[],
|
||||||
|
value: Partial<Scope>,
|
||||||
|
key: keyof Scope,
|
||||||
|
next: string | number,
|
||||||
|
): Partial<Scope> {
|
||||||
|
const draft = { ...value, [key]: next };
|
||||||
|
for (const child of scopeFields.slice(scopeFields.indexOf(key) + 1)) {
|
||||||
|
const values = scopeValues(rows, draft, child);
|
||||||
|
if (!values.includes(draft[child] as string | number)) {
|
||||||
|
Object.assign(draft, {
|
||||||
|
[child]: values.length === 1 ? values[0] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
export function validScope(
|
||||||
|
rows: ScopeOption[],
|
||||||
|
value: Partial<Scope>,
|
||||||
|
): value is Scope {
|
||||||
|
return rows.some(
|
||||||
|
(row) =>
|
||||||
|
row.instrument_type === value.instrument_type &&
|
||||||
|
row.region === value.region &&
|
||||||
|
row.delay === value.delay &&
|
||||||
|
row.universes.includes(value.universe ?? ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function asScopeOptions(rows: SettingsRow[]): ScopeOption[] {
|
||||||
|
return rows.map((row) => ({ ...row, universes: [row.universe] }));
|
||||||
|
}
|
||||||
|
export const parameterLabels: Record<string, string> = {
|
||||||
|
neutralization: "Neutralization",
|
||||||
|
decay: "Decay",
|
||||||
|
truncation: "Truncation",
|
||||||
|
pasteurization: "Pasteurization",
|
||||||
|
nanHandling: "NaN Handling",
|
||||||
|
maxTrade: "Max Trade",
|
||||||
|
maxPosition: "Max Position",
|
||||||
|
unitHandling: "Unit Handling",
|
||||||
|
language: "Language",
|
||||||
|
visualization: "Visualization",
|
||||||
|
};
|
||||||
|
// These are application contract limits, not a substitute for account-specific market choices.
|
||||||
|
const contractFields: Record<string, FieldOption> = {
|
||||||
|
decay: { minimum: 0, maximum: 10000 },
|
||||||
|
truncation: { minimum: 0, maximum: 1 },
|
||||||
|
pasteurization: { choices: ["ON", "OFF"] },
|
||||||
|
nanHandling: { choices: ["ON", "OFF"] },
|
||||||
|
maxTrade: { choices: ["ON", "OFF"] },
|
||||||
|
maxPosition: { choices: ["ON", "OFF"] },
|
||||||
|
unitHandling: { choices: ["VERIFY"] },
|
||||||
|
language: { choices: ["FASTEXPR"] },
|
||||||
|
visualization: { choices: [false, true] },
|
||||||
|
};
|
||||||
|
export function fieldOptions(
|
||||||
|
row: SettingsRow | undefined,
|
||||||
|
key: string,
|
||||||
|
): FieldOption {
|
||||||
|
if (key === "neutralization") return { choices: row?.neutralizations ?? [] };
|
||||||
|
const contract = contractFields[key] ?? {};
|
||||||
|
const platform = row?.fields?.[key];
|
||||||
|
const result: FieldOption = {
|
||||||
|
...contract,
|
||||||
|
...platform,
|
||||||
|
...(platform?.choices && contract.choices
|
||||||
|
? {
|
||||||
|
choices: platform.choices.filter((v) =>
|
||||||
|
contract.choices!.includes(v),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(contract.minimum !== undefined
|
||||||
|
? {
|
||||||
|
minimum: Math.max(
|
||||||
|
contract.minimum,
|
||||||
|
platform?.minimum ?? contract.minimum,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(contract.maximum !== undefined
|
||||||
|
? {
|
||||||
|
maximum: Math.min(
|
||||||
|
contract.maximum,
|
||||||
|
platform?.maximum ?? contract.maximum,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
if (result.choices && (key === "decay" || key === "truncation")) {
|
||||||
|
result.choices = result.choices.filter(
|
||||||
|
(v) =>
|
||||||
|
typeof v === "number" &&
|
||||||
|
Number.isFinite(v) &&
|
||||||
|
v >= result.minimum! &&
|
||||||
|
v <= result.maximum! &&
|
||||||
|
(key !== "decay" || Number.isInteger(v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/** Validate the exact values that will be submitted; never repair historical settings silently. */
|
||||||
|
export function settingsErrors(
|
||||||
|
value: SimulationSettings,
|
||||||
|
rows: SettingsRow[],
|
||||||
|
fixedScopes?: Scope[],
|
||||||
|
): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const scope = settingsScope(value);
|
||||||
|
const row = rows.find((r) => sameScope(r, scope));
|
||||||
|
if (!row) errors.push("当前组合不在可用选项中,请选择组合或同步合法设置");
|
||||||
|
if (
|
||||||
|
fixedScopes &&
|
||||||
|
(!fixedScopes.length || fixedScopes.some((s) => !sameScope(s, scope)))
|
||||||
|
)
|
||||||
|
errors.push("所选数据准备必须具有相同组合,并与回测组合一致");
|
||||||
|
for (const key of Object.keys(parameterLabels)) {
|
||||||
|
const current =
|
||||||
|
value[key as keyof SimulationSettings] ??
|
||||||
|
(key === "maxPosition" ? "OFF" : undefined);
|
||||||
|
const options = fieldOptions(row, key);
|
||||||
|
if (
|
||||||
|
options.choices &&
|
||||||
|
!options.choices.includes(current as string | number | boolean)
|
||||||
|
)
|
||||||
|
errors.push(
|
||||||
|
`${parameterLabels[key]} 当前值 ${String(current ?? "未提供")} 不可用,请重新选择`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(key === "decay" || key === "truncation") &&
|
||||||
|
(typeof current !== "number" ||
|
||||||
|
!Number.isFinite(current) ||
|
||||||
|
current < options.minimum! ||
|
||||||
|
current > options.maximum! ||
|
||||||
|
(key === "decay" && !Number.isInteger(current)))
|
||||||
|
)
|
||||||
|
errors.push(
|
||||||
|
`${parameterLabels[key]} 必须为 ${options.minimum}–${options.maximum} 的${key === "decay" ? "整数" : "数值"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.scope-picker {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.scope-picker-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.scope-picker-summary > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.scope-picker-summary > button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.scope-picker-summary small,
|
||||||
|
.settings-hint {
|
||||||
|
color: var(--semi-color-text-2);
|
||||||
|
}
|
||||||
|
.settings-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.scope-picker-panel {
|
||||||
|
padding: 16px 12px;
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
border-top: 0;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-field > .semi-select,
|
||||||
|
.settings-field > .semi-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.settings-error {
|
||||||
|
color: var(--semi-color-danger);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.simulation-settings {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.simulation-settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.simulation-settings details > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import type { SettingsSnapshot } from "./model";
|
||||||
|
|
||||||
|
/** Read the saved snapshot on entry; refresh explicitly to keep account reads bounded. */
|
||||||
|
export function useSettingsOptions(active = true) {
|
||||||
|
const [snapshot, setSnapshot] = useState<SettingsSnapshot>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const load = useCallback(async (refresh = false) => {
|
||||||
|
controller.current?.abort();
|
||||||
|
const c = new AbortController();
|
||||||
|
controller.current = c;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api<SettingsSnapshot>(
|
||||||
|
`/catalog/setting-options${refresh ? "/refresh" : ""}`,
|
||||||
|
{
|
||||||
|
method: refresh ? "POST" : "GET",
|
||||||
|
signal: c.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!c.signal.aborted) setSnapshot(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (!c.signal.aborted) setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
if (!c.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) void load();
|
||||||
|
return () => controller.current?.abort();
|
||||||
|
}, [active, load]);
|
||||||
|
return {
|
||||||
|
rows: snapshot?.content.items ?? [],
|
||||||
|
fetchedAt: snapshot?.fetched_at,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: () => load(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,10 +27,16 @@ test("draft, immutable preview, mixed-result persistence and responsive workspac
|
|||||||
await login(page);
|
await login(page);
|
||||||
await page.getByRole("button", { name: "新建回测" }).click();
|
await page.getByRole("button", { name: "新建回测" }).click();
|
||||||
await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收");
|
await page.getByLabel("运行名称", { exact: true }).fill("浏览器回测验收");
|
||||||
await page.getByRole("textbox", { name: "Region", exact: true }).fill("USA");
|
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "选择组合", exact: true }).click();
|
||||||
|
await page.getByRole("combobox", { name: "Region", exact: true }).click();
|
||||||
|
await page.getByRole("option").filter({ hasText: /^USA$/ }).click();
|
||||||
|
await page.getByRole("combobox", { name: "Universe", exact: true }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("textbox", { name: "Universe", exact: true })
|
.getByRole("option")
|
||||||
.fill("TOP3000");
|
.filter({ hasText: /^TOP3000$/ })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)");
|
await page.getByLabel("回测候选").fill("rank(close)\n-rank(volume)");
|
||||||
await page.getByRole("button", { name: "保存草稿", exact: true }).click();
|
await page.getByRole("button", { name: "保存草稿", exact: true }).click();
|
||||||
await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible();
|
await expect(page.getByText("草稿已保存", { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -129,16 +129,23 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
|
|||||||
).toHaveAttribute("aria-selected", "true");
|
).toHaveAttribute("aria-selected", "true");
|
||||||
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
|
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
|
||||||
const online = page.getByRole("tabpanel", { name: "worldquant接口" });
|
const online = page.getByRole("tabpanel", { name: "worldquant接口" });
|
||||||
|
await online
|
||||||
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
online.getByRole("button", { name: "查询", exact: true }),
|
page.getByRole("button", { name: "查询", exact: true }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
await expect(
|
await expect(
|
||||||
online.getByText("请输入关键词、数据集或其他筛选条件,然后点击查询。"),
|
online.getByText("请输入关键词、数据集或其他筛选条件,然后点击查询。"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(queries).toEqual([]);
|
expect(queries).toEqual([]);
|
||||||
await online.getByLabel("字段数据集").fill("TEST_FIN");
|
if (!(await page.getByRole("form", { name: "字段目录筛选条件" }).isVisible()))
|
||||||
|
await online
|
||||||
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("字段数据集").fill("TEST_FIN");
|
||||||
expect(queries).toEqual([]);
|
expect(queries).toEqual([]);
|
||||||
await online.getByRole("button", { name: "查询", exact: true }).click();
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
||||||
await online
|
await online
|
||||||
.getByRole("row")
|
.getByRole("row")
|
||||||
@@ -166,8 +173,11 @@ test("在线字段跨页多选直接准备,本地只展示完整同步字段",
|
|||||||
await page.getByRole("tab", { name: "本地同步", exact: true }).click();
|
await page.getByRole("tab", { name: "本地同步", exact: true }).click();
|
||||||
const local = page.getByRole("tabpanel", { name: "本地同步" });
|
const local = page.getByRole("tabpanel", { name: "本地同步" });
|
||||||
// Other browser tests share this isolated server, so verify the unsynced dataset explicitly.
|
// Other browser tests share this isolated server, so verify the unsynced dataset explicitly.
|
||||||
await local.getByLabel("字段数据集").fill("TEST_NEWS");
|
await local
|
||||||
await local.getByRole("button", { name: "查询", exact: true }).click();
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("字段数据集").fill("TEST_NEWS");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(local.getByText("共 0 个字段", { exact: true })).toBeVisible();
|
await expect(local.getByText("共 0 个字段", { exact: true })).toBeVisible();
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: "test-results/preparations-fields.png",
|
path: "test-results/preparations-fields.png",
|
||||||
@@ -239,19 +249,174 @@ test("目录表格保持紧凑行高、截断和固定分页", async ({ page })
|
|||||||
await page.screenshot({ path: "test-results/field-table-layout-narrow.png" });
|
await page.screenshot({ path: "test-results/field-table-layout-narrow.png" });
|
||||||
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
|
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
|
||||||
const online = page.getByRole("tabpanel", { name: "worldquant接口" });
|
const online = page.getByRole("tabpanel", { name: "worldquant接口" });
|
||||||
await online.getByLabel("字段数据集").fill("TEST_FIN");
|
if (!(await page.getByRole("form", { name: "字段目录筛选条件" }).isVisible()))
|
||||||
await online.getByRole("button", { name: "查询", exact: true }).click();
|
await online
|
||||||
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("字段数据集").fill("TEST_FIN");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
await expect(online.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
||||||
let queries = 0;
|
let queries = 0;
|
||||||
page.on("request", (req) => {
|
page.on("request", (req) => {
|
||||||
if (req.url().includes("/catalog/worldquant/fields")) queries++;
|
if (req.url().includes("/catalog/worldquant/fields")) queries++;
|
||||||
});
|
});
|
||||||
await online.getByRole("button", { name: "重置", exact: true }).click();
|
await online
|
||||||
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "重置", exact: true }).click();
|
||||||
|
await online
|
||||||
|
.getByRole("button", { name: "筛选字段目录", exact: true })
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
online.getByRole("button", { name: "查询", exact: true }),
|
page.getByRole("button", { name: "查询", exact: true }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
await expect(online.getByText("TEST_FIN_001", { exact: true })).toHaveCount(
|
await expect(online.getByText("TEST_FIN_001", { exact: true })).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
expect(queries).toBe(0);
|
expect(queries).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function selectToolbarOption(page: Page, label: string, value: string) {
|
||||||
|
const control = page.getByRole("combobox", { name: label, exact: true });
|
||||||
|
await control.click();
|
||||||
|
const popup = page.locator(
|
||||||
|
`[id="${await control.getAttribute("aria-controls")}"]`,
|
||||||
|
);
|
||||||
|
await popup
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: new RegExp(`^${value}$`) })
|
||||||
|
.click();
|
||||||
|
await expect(control).toContainText(value);
|
||||||
|
await expect(popup).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("共用工具栏延迟应用筛选、保存列设置并发送服务端排序", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
await sync(page, null);
|
||||||
|
await sync(page, "TEST_FIN");
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("row").filter({ hasText: "TEST 财务报表" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (
|
||||||
|
req.url().includes("/catalog/datasets?") ||
|
||||||
|
req.url().includes("/catalog/fields?") ||
|
||||||
|
req.url().includes("/data-preparations?")
|
||||||
|
)
|
||||||
|
requests.push(req.url());
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "筛选数据目录", exact: true }).click();
|
||||||
|
const filter = page.getByRole("form", { name: "数据目录筛选条件" });
|
||||||
|
await filter.getByLabel("搜索数据集").fill("TEST_FIN");
|
||||||
|
expect(requests.some((url) => url.includes("q=TEST_FIN"))).toBe(false);
|
||||||
|
await filter.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some((url) => new URL(url).searchParams.get("q") === "TEST_FIN"),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "显示列设置", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.locator(".catalog-column-panel .semi-checkbox")
|
||||||
|
.filter({ hasText: "价值评分" })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "价值评分", exact: true }),
|
||||||
|
).not.toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("columnheader", { name: "价值评分", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("row").filter({ hasText: "TEST 财务报表" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("columnheader", { name: "价值评分", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "排序设置", exact: true }).click();
|
||||||
|
await selectToolbarOption(page, "排序字段", "字段数");
|
||||||
|
await selectToolbarOption(page, "排序方向", "降序");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) =>
|
||||||
|
url.includes("sort=field_count") && url.includes("direction=desc"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.screenshot({ path: "test-results/catalog-sort-popover.png" });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "主导航" })
|
||||||
|
.getByRole("button", { name: "字段目录", exact: true })
|
||||||
|
.click();
|
||||||
|
const local = page.getByRole("tabpanel", { name: "本地同步" });
|
||||||
|
await expect(local.getByText("TEST_FIN_001", { exact: true })).toBeVisible();
|
||||||
|
await local.locator(".semi-page-item").filter({ hasText: /^2$/ }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) => url.includes("/catalog/fields?") && url.includes("offset=25"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await local.getByRole("button", { name: "排序设置", exact: true }).click();
|
||||||
|
await selectToolbarOption(page, "排序方向", "降序");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) =>
|
||||||
|
url.includes("/catalog/fields?") &&
|
||||||
|
url.includes("direction=desc") &&
|
||||||
|
url.includes("offset=0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.getByRole("tab", { name: "worldquant接口", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("tabpanel", { name: "worldquant接口" })
|
||||||
|
.getByRole("button", { name: "排序设置", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "主导航" })
|
||||||
|
.getByRole("button", { name: "数据准备", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "筛选数据准备", exact: true }).click();
|
||||||
|
await page.getByLabel("搜索准备集合").fill("toolbar-preparation");
|
||||||
|
await page
|
||||||
|
.locator(".catalog-filter-panel .semi-checkbox")
|
||||||
|
.filter({ hasText: "按范围筛选" })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "更改组合", exact: true }).click();
|
||||||
|
await selectToolbarOption(page, "Region", "USA");
|
||||||
|
await page.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("form", { name: "数据准备筛选条件" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(requests.some((url) => url.includes("q=toolbar-preparation"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "test-results/preparation-filter-popover.png",
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => requests.some((url) => url.includes("q=toolbar-preparation")))
|
||||||
|
.toBe(true);
|
||||||
|
await page.getByRole("button", { name: "排序设置", exact: true }).click();
|
||||||
|
await selectToolbarOption(page, "排序字段", "集合名称");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.some(
|
||||||
|
(url) =>
|
||||||
|
url.includes("/data-preparations?") && url.includes("sort=name"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ test("feature conversion, saved views and immutable evaluations", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("textbox", { name: "模板名称", exact: true }),
|
page.getByRole("textbox", { name: "模板名称", exact: true }),
|
||||||
).toHaveValue("特征转换模板");
|
).toHaveValue("特征转换模板");
|
||||||
|
const settingsEditor = page.getByRole("region", {
|
||||||
|
name: "回测参数",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await settingsEditor.getByRole("button", { name: "同步合法设置" }).click();
|
||||||
|
await expect(
|
||||||
|
settingsEditor.getByRole("combobox", {
|
||||||
|
name: "Neutralization",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toContainText("INDUSTRY");
|
||||||
|
await expect(
|
||||||
|
settingsEditor.getByRole("button", { name: "更改组合" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(settingsEditor).toContainText("来自固定数据准备");
|
||||||
const templates = await (
|
const templates = await (
|
||||||
await page.request.get(
|
await page.request.get(
|
||||||
"/api/v1/research/assets?kind=template&q=特征转换模板",
|
"/api/v1/research/assets?kind=template&q=特征转换模板",
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { initialSettings } from "../src/backtests/types";
|
||||||
|
import {
|
||||||
|
changeScope,
|
||||||
|
fieldOptions,
|
||||||
|
settingsErrors,
|
||||||
|
type SettingsRow,
|
||||||
|
} from "../src/settings/model";
|
||||||
|
const headers = { "X-WQ-Request": "1" };
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto("/#backtests");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "新建回测" })).toBeVisible();
|
||||||
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
|
headers,
|
||||||
|
data: { email: "test@example.com", password: "synthetic-password" },
|
||||||
|
});
|
||||||
|
await page.request.post("/api/v1/account/connect", { headers });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/account")).json())
|
||||||
|
.connection_status,
|
||||||
|
)
|
||||||
|
.toBe("connected");
|
||||||
|
}
|
||||||
|
async function select(page: Page, name: string, option: string) {
|
||||||
|
const control = page.getByRole("combobox", { name, exact: true });
|
||||||
|
await control.click();
|
||||||
|
const popup = page.locator(
|
||||||
|
`[id="${await control.getAttribute("aria-controls")}"]`,
|
||||||
|
);
|
||||||
|
await popup
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: new RegExp(`^${option}$`) })
|
||||||
|
.click();
|
||||||
|
await expect(control).toContainText(option);
|
||||||
|
await expect(popup).not.toBeVisible();
|
||||||
|
}
|
||||||
|
async function openEditor(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "新建回测", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "选择组合", exact: true }).click();
|
||||||
|
await select(page, "Region", "USA");
|
||||||
|
await select(page, "Universe", "TOP3000");
|
||||||
|
await page.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
|
}
|
||||||
|
test("scope changes are transactional and explicit parameters reach the preview unchanged", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await login(page);
|
||||||
|
await openEditor(page);
|
||||||
|
const picker = page.getByRole("region", { name: "组合选择", exact: true });
|
||||||
|
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
|
||||||
|
await page.getByRole("button", { name: "更改组合", exact: true }).click();
|
||||||
|
await select(page, "Region", "CHN");
|
||||||
|
await expect(picker).toContainText("TOP2000U");
|
||||||
|
await picker.getByRole("button", { name: "取消", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
|
||||||
|
await page.getByRole("button", { name: "更改组合", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("combobox", { name: "Region", exact: true }),
|
||||||
|
).toContainText("USA");
|
||||||
|
await select(page, "Region", "CHN");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("combobox", { name: "Region", exact: true }),
|
||||||
|
).toContainText("CHN");
|
||||||
|
await page.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("EQUITY / CHN / TOP2000U / Delay 1");
|
||||||
|
await select(page, "Neutralization", "NONE");
|
||||||
|
await page.getByLabel("Decay", { exact: true }).fill("12");
|
||||||
|
await page.getByText("更多参数", { exact: true }).click();
|
||||||
|
await select(page, "NaN Handling", "ON");
|
||||||
|
await select(page, "Max Position", "ON");
|
||||||
|
await select(page, "Visualization", "是");
|
||||||
|
await expect(page.getByLabel("其他参数", { exact: true })).toHaveCount(0);
|
||||||
|
await page.getByLabel("运行名称", { exact: true }).fill("组合参数一致性");
|
||||||
|
await page.getByLabel("回测候选").fill("rank(close)");
|
||||||
|
for (const width of [1440, 900, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
const bounds = await page
|
||||||
|
.locator(".simulation-settings .settings-field:visible")
|
||||||
|
.evaluateAll((nodes) =>
|
||||||
|
nodes.map((node) => {
|
||||||
|
const r = node.getBoundingClientRect();
|
||||||
|
return { left: r.left, right: r.right, width: r.width };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
bounds.every((r) => r.left >= 0 && r.right <= width + 1 && r.width > 80),
|
||||||
|
).toBeTruthy();
|
||||||
|
await page.screenshot({
|
||||||
|
path: `../output/playwright/shared-settings-${width}.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith("/backtests/previews") &&
|
||||||
|
r.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "预览回测", exact: true }).click();
|
||||||
|
const preview = await (await response).json();
|
||||||
|
expect(preview.items[0].settings).toMatchObject({
|
||||||
|
region: "CHN",
|
||||||
|
universe: "TOP2000U",
|
||||||
|
neutralization: "NONE",
|
||||||
|
decay: 12,
|
||||||
|
nanHandling: "ON",
|
||||||
|
maxPosition: "ON",
|
||||||
|
visualization: true,
|
||||||
|
});
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
test("metadata failure retries and invalid imported settings stay visible without submission", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
await page.getByRole("button", { name: "新建回测", exact: true }).click();
|
||||||
|
await page.route("**/catalog/setting-options/refresh", (route) =>
|
||||||
|
route.fulfill({ status: 502, json: { detail: "模拟选项同步失败" } }),
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
|
||||||
|
await expect(page.getByText(/模拟选项同步失败/)).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "预览回测", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await page.unroute("**/catalog/setting-options/refresh");
|
||||||
|
await page.getByRole("button", { name: "同步合法设置", exact: true }).click();
|
||||||
|
await expect(page.getByText(/模拟选项同步失败/)).toHaveCount(0);
|
||||||
|
await page.getByText("每行一条表达式,共用参数", { exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("option")
|
||||||
|
.filter({ hasText: "结构化候选 JSON,逐项完整参数" })
|
||||||
|
.click();
|
||||||
|
const text = JSON.stringify([
|
||||||
|
{
|
||||||
|
client_item_id: "old",
|
||||||
|
expression: "rank(close)",
|
||||||
|
settings: {
|
||||||
|
...initialSettings,
|
||||||
|
region: "USA",
|
||||||
|
universe: "TOP3000",
|
||||||
|
neutralization: "REMOVED_VALUE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page.getByLabel("回测候选").fill(text);
|
||||||
|
let submits = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (
|
||||||
|
request.url().endsWith("/backtests/previews") &&
|
||||||
|
request.method() === "POST"
|
||||||
|
)
|
||||||
|
submits++;
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "预览回测", exact: true }).click();
|
||||||
|
await expect(page.getByText(/REMOVED_VALUE 不可用/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel("回测候选")).toHaveValue(text);
|
||||||
|
expect(submits).toBe(0);
|
||||||
|
});
|
||||||
|
test("scope invalidation and platform restrictions preserve types and reject incompatible fixed inputs", () => {
|
||||||
|
const scope = {
|
||||||
|
instrument_type: "EQUITY",
|
||||||
|
region: "USA",
|
||||||
|
delay: 1,
|
||||||
|
universe: "TOP3000",
|
||||||
|
};
|
||||||
|
const options = [
|
||||||
|
{ ...scope, universes: ["TOP3000"] },
|
||||||
|
{ ...scope, region: "CHN", universes: ["TOP2000U", "TOP500"] },
|
||||||
|
];
|
||||||
|
expect(changeScope(options, scope, "region", "CHN")).toEqual({
|
||||||
|
...scope,
|
||||||
|
region: "CHN",
|
||||||
|
universe: undefined,
|
||||||
|
});
|
||||||
|
const row: SettingsRow = {
|
||||||
|
...scope,
|
||||||
|
neutralizations: ["NONE"],
|
||||||
|
fields: {
|
||||||
|
visualization: { choices: [false] },
|
||||||
|
decay: { minimum: -10, maximum: 20000 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(fieldOptions(row, "decay")).toEqual({ minimum: 0, maximum: 10000 });
|
||||||
|
expect(fieldOptions(row, "visualization").choices).toEqual([false]);
|
||||||
|
expect(
|
||||||
|
fieldOptions(
|
||||||
|
{ ...row, fields: { decay: { choices: [-1, 0, 1.5, 10, 20000] } } },
|
||||||
|
"decay",
|
||||||
|
).choices,
|
||||||
|
).toEqual([0, 10]);
|
||||||
|
expect(
|
||||||
|
settingsErrors(
|
||||||
|
{
|
||||||
|
...initialSettings,
|
||||||
|
region: "USA",
|
||||||
|
universe: "TOP3000",
|
||||||
|
neutralization: "NONE",
|
||||||
|
},
|
||||||
|
[row],
|
||||||
|
[scope],
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
const errors = settingsErrors(
|
||||||
|
{
|
||||||
|
...initialSettings,
|
||||||
|
region: "USA",
|
||||||
|
universe: "TOP3000",
|
||||||
|
decay: 0.5,
|
||||||
|
visualization: "false" as unknown as boolean,
|
||||||
|
},
|
||||||
|
[row],
|
||||||
|
[{ ...scope, region: "CHN" }],
|
||||||
|
);
|
||||||
|
expect(errors.join(" ")).toContain("相同组合");
|
||||||
|
expect(errors.join(" ")).toContain("INDUSTRY 不可用");
|
||||||
|
expect(errors.join(" ")).toContain("整数");
|
||||||
|
expect(errors.join(" ")).toContain("Visualization");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new preparation edits its scope inline and cancels without changing the collection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await login(page);
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "主导航" })
|
||||||
|
.getByRole("button", { name: "数据准备", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole("button", { name: "新建集合", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", {
|
||||||
|
name: "新建数据准备集合",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const picker = dialog.getByRole("region", { name: "组合选择", exact: true });
|
||||||
|
await picker.getByRole("button", { name: "更改组合" }).click();
|
||||||
|
await select(page, "Region", "CHN");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(1);
|
||||||
|
await picker.getByRole("button", { name: "取消", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("EQUITY / USA / TOP3000 / Delay 1");
|
||||||
|
await picker.getByRole("button", { name: "更改组合" }).click();
|
||||||
|
await select(page, "Region", "CHN");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "../output/playwright/preparation-scope-picker.png",
|
||||||
|
});
|
||||||
|
await picker.getByRole("button", { name: "应用组合", exact: true }).click();
|
||||||
|
await expect(picker).toContainText("EQUITY / CHN / TOP2000U / Delay 1");
|
||||||
|
await dialog.getByLabel("集合名称", { exact: true }).fill("组合组件新建验收");
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith("/data-preparations") && r.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await dialog.getByRole("button", { name: "confirm", exact: true }).click();
|
||||||
|
expect((await (await response).json()).scope).toMatchObject({
|
||||||
|
region: "CHN",
|
||||||
|
universe: "TOP2000U",
|
||||||
|
delay: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user