diff --git a/.scratch/catalog-table-interactions/issues/01-implementation.md b/.scratch/catalog-table-interactions/issues/01-implementation.md new file mode 100644 index 0000000..49c614d --- /dev/null +++ b/.scratch/catalog-table-interactions/issues/01-implementation.md @@ -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 与大包体积警告。 diff --git a/.scratch/catalog-table-interactions/spec.md b/.scratch/catalog-table-interactions/spec.md new file mode 100644 index 0000000..86af9a4 --- /dev/null +++ b/.scratch/catalog-table-interactions/spec.md @@ -0,0 +1,5 @@ +# 统一目录表格交互 + +用户要求数据目录、字段目录、数据准备的表格交互与 Alpha 管理一致。统一筛选浮层(草稿/查询应用/重置)、列显隐持久化、独立排序浮层与左右工具栏;常用动作图标化,保留 aria-label 与提示。保留已完成的组合组件;不新增未授权的保存视图、导出或上游排序能力。服务端分页保持全量排序,在线字段仍需明确条件并点击查询,切换范围清空结果/勾选。 + +本地验证覆盖筛选生效时机、排序跨页、列配置刷新恢复、图标名称、40px 行高和窄屏布局。保留前一任务全部改动。 diff --git a/.scratch/shared-simulation-settings/issues/01-implementation.md b/.scratch/shared-simulation-settings/issues/01-implementation.md new file mode 100644 index 0000000..c2bc36f --- /dev/null +++ b/.scratch/shared-simulation-settings/issues/01-implementation.md @@ -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 与包体积警告。 diff --git a/.scratch/shared-simulation-settings/spec.md b/.scratch/shared-simulation-settings/spec.md new file mode 100644 index 0000000..f5252b7 --- /dev/null +++ b/.scratch/shared-simulation-settings/spec.md @@ -0,0 +1,5 @@ +# 通用组合与回测参数组件 + +用户已确认开发。组合采用左摘要、右选择按钮,展开后以临时值编辑,应用时原子提交;取消不影响调用方。保留平台、本地与固定输入三种范围语义。回测表单复用组合选择;有 choices 的字段使用下拉,范围数值使用数字输入。参数选项与联动校验集中维护,历史非法值可见但不可预览。移除共用参数 JSON 隐式覆盖,保留独立候选 JSON 模式及服务端校验。 + +接入数据准备、数据/字段目录、回测准备、研究工作台、自动研究启动。补齐设置快照字段元数据,保留已有 items 兼容性。验收覆盖选项解析、联动/取消、固定范围、错误重试、最终参数一致性与窄屏。 diff --git a/backend/app/catalog/research_metadata.py b/backend/app/catalog/research_metadata.py index 0b24356..0826df9 100644 --- a/backend/app/catalog/research_metadata.py +++ b/backend/app/catalog/research_metadata.py @@ -30,7 +30,7 @@ def setting_rows(data): try: 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"] if isinstance(value, dict) and "instrumentType" in value: value = value["instrumentType"][instrument] @@ -38,6 +38,14 @@ def setting_rows(data): value = value[instrument] if isinstance(value, dict) and "region" in value: 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] rows = [] @@ -48,12 +56,29 @@ def setting_rows(data): for delay in choices("delay", instrument, region): if type(delay) is not int or delay not in (0, 1): continue - for universe in choices("universe", instrument, region): + for universe in choices("universe", instrument, region, delay): neutralizations = ( - choices("neutralization", instrument, region) + choices("neutralization", instrument, region, delay, universe) if "neutralization" in children 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( { "instrument_type": instrument, @@ -61,6 +86,7 @@ def setting_rows(data): "universe": universe, "delay": delay, "neutralizations": neutralizations, + "fields": fields, } ) if not rows: diff --git a/backend/app/preparations/routes.py b/backend/app/preparations/routes.py index 04094c7..234d1ef 100644 --- a/backend/app/preparations/routes.py +++ b/backend/app/preparations/routes.py @@ -1,6 +1,6 @@ """Authenticated preparation and field-directory endpoints.""" -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import delete, select @@ -55,9 +55,11 @@ async def preparations( scope_key: str | None = None, limit: int = Query(25, ge=1, le=100), 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: - 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) diff --git a/backend/app/preparations/service.py b/backend/app/preparations/service.py index 5bcd4ad..276d605 100644 --- a/backend/app/preparations/service.py +++ b/backend/app/preparations/service.py @@ -138,9 +138,12 @@ class Preparations: async def online_fields(self, filters): text_filters = (filters.q, filters.dataset_id, filters.field_type) numeric_filters = ( - filters.coverage_min, filters.coverage_max, - filters.user_count_min, filters.user_count_max, - filters.alpha_count_min, filters.alpha_count_max, + filters.coverage_min, + filters.coverage_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( 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) if 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())) - rows = await self.db.scalars( - query.order_by(DataPreparation.updated_at.desc(), DataPreparation.id).limit(limit).offset(offset) - ) + column = { + "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) async def members(self, preparation_id, q="", dataset_id=None, limit=25, offset=0): diff --git a/backend/tests/test_preparation_sort.py b/backend/tests/test_preparation_sort.py new file mode 100644 index 0000000..0b7fdff --- /dev/null +++ b/backend/tests/test_preparation_sort.py @@ -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" diff --git a/backend/tests/test_settings_metadata.py b/backend/tests/test_settings_metadata.py new file mode 100644 index 0000000..2e6e982 --- /dev/null +++ b/backend/tests/test_settings_metadata.py @@ -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"] diff --git a/frontend/src/backtests/BacktestPage.tsx b/frontend/src/backtests/BacktestPage.tsx index de8587b..e1cb5c0 100644 --- a/frontend/src/backtests/BacktestPage.tsx +++ b/frontend/src/backtests/BacktestPage.tsx @@ -1,3 +1,6 @@ +import { SimulationSettingsEditor } from "../settings/SimulationSettingsEditor"; +import { useSettingsOptions } from "../settings/useSettingsOptions"; +import { settingsErrors } from "../settings/model"; import { ResearchDataInput, researchSelection, @@ -104,7 +107,11 @@ export function BacktestPage({ const [text, setText] = useState(""); const [mode, setMode] = useState("lines"); const [settings, setSettings] = useState(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(null); const [excluded, setExcluded] = useState([]); const [referenceUrls, setReferenceUrls] = useState>( @@ -250,12 +257,13 @@ export function BacktestPage({ setText(""); setSettings(initialSettings); setMode("lines"); - setAdvanced(""); setPreview(null); setEditor(true); setRunId(""); } function input() { + if (options.loading || options.error) + throw new Error("请等待参数选项加载完成,或重试同步合法设置"); const candidates: Candidate[] = mode === "json" ? JSON.parse(text) @@ -266,13 +274,20 @@ export function BacktestPage({ .map((expression, i) => ({ client_item_id: `item-${i + 1}`, expression, - settings: { - ...settings, - ...(advanced.trim() ? JSON.parse(advanced) : {}), - }, + settings: { ...settings }, })); if (!Array.isArray(candidates) || !candidates.length) 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) }; } async function loadDraft(id: string) { @@ -488,7 +503,7 @@ export function BacktestPage({ setEditor(false)} @@ -614,12 +629,24 @@ export function BacktestPage({ ) : ( <>
-