Compare commits

...

2 Commits

Author SHA1 Message Date
yuxuanhui 8981c24fda feat: unify scope settings and catalog table interactions
Deploy production / deploy (push) Successful in 54s
2026-09-12 12:11:53 +08:00
yuxuanhui 9dee2de039 feat: 更新首页信息处理模型,支持内存缓存与过期机制;添加全量目录同步脚本 2026-09-12 10:33:40 +08:00
36 changed files with 2816 additions and 816 deletions
@@ -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 兼容性。验收覆盖选项解析、联动/取消、固定范围、错误重试、最终参数一致性与窄屏。
+1 -1
View File
@@ -263,7 +263,7 @@ AI 模型兼容性由模拟 Chat Completions/Responses HTTP 流与真实 SDK 适
### 首页信息与基础信息处理模型 ### 首页信息与基础信息处理模型
首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。 首页提供平台消息、顾问日度排名、已参加比赛与协议、未来活动,独立刷新并保存上次成功快照。公告与社区资讯仅保留最近一个自然月(按美东时间回推),正文及其 AI 解读只暂存在进程内存中,15 分钟到期或服务重启后清除,不存数据库;消息逐页浏览,活动读取平台分页后按结束时间过滤;日期或排名缺失保持未知。刷新失败保留缓存并提示过期。
在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。 在大模型服务设置“基础信息处理模型”(沿用 description_model 字段),与 alpha研究使用模型共享连接配置。该模型继续生成 Alpha Description,并用于手动生成首页摘要及比赛规则解读;未配置时仍可查看原始信息,不回退到研究模型。解读保存来源版本及所用模型,来源或连接配置变化后提示待更新,不自动调用模型。比赛解读附原文依据,仅提供信息建议,不执行报名、接受协议或安排任务。
+29 -3
View File
@@ -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:
+86 -8
View File
@@ -4,6 +4,7 @@ Only allowlisted platform paths are fetched. HTML is converted to inert text;
platform content is never an instruction and never supplies model/tool endpoints. platform content is never an instruction and never supplies model/tool endpoints.
""" """
import asyncio import asyncio
import calendar
import hashlib import hashlib
import json import json
import math import math
@@ -131,10 +132,10 @@ def next_offset(data, path, current):
raise WqError("平台分页信息无法识别", "invalid_response") from None raise WqError("平台分页信息无法识别", "invalid_response") from None
async def all_pages(client, path): async def all_pages(client, path, limit=None):
output, offset = [], 0 output, offset = [], 0
for _ in range(100): for _ in range(100):
data = await client.get(path, params={"offset": offset}) data = await client.get(path, params={"offset": offset, **({"limit": limit} if limit else {})})
output.extend(rows(data)) output.extend(rows(data))
offset = next_offset(data, path, offset) offset = next_offset(data, path, offset)
if offset is None: if offset is None:
@@ -160,14 +161,17 @@ def competition(item, user):
async def source(client, module, user, offset, cid): async def source(client, module, user, offset, cid):
if module == "messages": if module == "messages":
path = "/users/self/messages" path = "/users/self/messages"
data = await client.get(path, params={"offset": offset})
items = [] items = []
for item in rows(data): for item in await all_pages(client, path, limit=100):
created = instant(item.get("dateCreated"))
if not created or not month_start() <= created <= now():
continue
body, links = plain(item.get("description")) body, links = plain(item.get("description"))
items.append(dict(id=text(item.get("id")), title=text(item.get("title")) or "平台消息", items.append(dict(id=text(item.get("id")), title=text(item.get("title")) or "平台消息",
type=text(item.get("type")), date=text(item.get("dateCreated")), type=text(item.get("type")), date=text(item.get("dateCreated")),
description=body, links=links, url="https://platform.worldquantbrain.com/messages/" + ("announcements" if item.get("type") == "ANNOUNCEMENT" else "notifications"))) description=body, links=links, url="https://platform.worldquantbrain.com/messages/" + ("announcements" if item.get("type") == "ANNOUNCEMENT" else "notifications")))
return dict(items=items, offset=offset, next_offset=next_offset(data, path, offset), total=number(data.get("count"))) items.sort(key=lambda item: instant(item["date"]), reverse=True)
return dict(items=items)
if module == "leaderboard": if module == "leaderboard":
data = await client.get("/consultant/boards/leader", params={"user": user}) data = await client.get("/consultant/boards/leader", params={"user": user})
matches = [r for r in rows(data) if r.get("user") == user] matches = [r for r in rows(data) if r.get("user") == user]
@@ -227,8 +231,74 @@ def config_version(config):
return digest([config.description_model, config.base_url, config.api_key_encrypted, config.protocol]) return digest([config.description_model, config.base_url, config.api_key_encrypted, config.protocol])
def month_start():
"""One calendar month before the Eastern wall time, clamped at month end."""
at = now().astimezone(EASTERN)
year, month = (at.year, at.month - 1) if at.month > 1 else (at.year - 1, 12)
return at.replace(year=year, month=month, day=min(at.day, calendar.monthrange(year, month)[1]))
def message_page(request, user, offset):
"""Return a detached page; neither source text nor AI output enters an ORM session."""
cached = request.app.state.home_message_cache.get(user)
if cached is None:
return None
if now() - cached.created_at >= timedelta(minutes=15):
del request.app.state.home_message_cache[user]
return None
content = None
if cached.content is not None:
# Enforce the rolling boundary even on cached reads and generation commits.
filtered = [item for item in cached.content["items"]
if month_start() <= instant(item["date"]) <= now()]
if len(filtered) != len(cached.content["items"]):
cached.analyses.clear()
cached.content = {"items": filtered}
items = cached.content["items"]
content = dict(items=items[offset:offset + 10], total=len(items), offset=offset,
next_offset=offset + 10 if offset + 10 < len(items) else None)
return SimpleNamespace(content=content, version=digest(content), fetched_at=cached.fetched_at,
error=cached.error, analysis=cached.analyses.get(offset))
async def read_messages(request, user, offset, refresh):
"""Keep only a 15-minute in-process cache; restart/expiry discards interpretations too."""
async with lock(request, (user, "messages")):
cache = request.app.state.home_message_cache
for owner, entry in list(cache.items()):
if now() - entry.created_at >= timedelta(minutes=15):
del cache[owner]
cached = cache.get(user)
if cached is not None and not refresh:
return message_page(request, user, offset)
try:
content = await source(request.app.state.runner.client, "messages", user, 0, "")
error = None
except WqError as exc:
content, error = None, str(exc)
if await identity(request) != user:
raise HTTPException(409, "账户已变化,请重新读取信息")
if cached is None:
cached = SimpleNamespace(content=None, fetched_at=None, created_at=now(), error=None, analyses={})
cache[user] = cached
cached.error = error
if content is not None:
if cached.content != content:
cached.analyses.clear()
cached.content, cached.fetched_at = content, now()
return message_page(request, user, offset)
async def cached_record(request, db, user, module, offset, key):
if module == "messages":
return message_page(request, user, offset)
return await db.get(HomeInformation, (user, key))
async def read_record(request, user, module, offset, cid, refresh=False): async def read_record(request, user, module, offset, cid, refresh=False):
key = resource_key(module, offset, cid) key = resource_key(module, offset, cid)
if module == "messages":
return await read_messages(request, user, offset, refresh)
async with lock(request, (user, key)): async with lock(request, (user, key)):
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
record = await db.get(HomeInformation, (user, key)) record = await db.get(HomeInformation, (user, key))
@@ -303,12 +373,16 @@ async def output(request, user, module, record):
config = await db.get(AISettings, 1) config = await db.get(AISettings, 1)
_, version = await analysis_sources(request, user, module, record) _, version = await analysis_sources(request, user, module, record)
analysis = record.analysis analysis = record.analysis
if module == "messages" and analysis and analysis["source_version"] != version:
request.app.state.home_message_cache[user].analyses.pop(record.content["offset"], None)
analysis = None
if analysis: if analysis:
analysis = {**analysis, "outdated": analysis["source_version"] != version or analysis["config_version"] != config_version(config)} analysis = {**analysis, "outdated": analysis["source_version"] != version or analysis["config_version"] != config_version(config)}
analysis.pop("config_version", None) analysis.pop("config_version", None)
return dict(content=temporal(record.content, module), fetched_at=record.fetched_at.replace(tzinfo=timezone.utc) if record.fetched_at else None, error=record.error, return dict(content=temporal(record.content, module), fetched_at=record.fetched_at.replace(tzinfo=timezone.utc) if record.fetched_at else None, error=record.error,
stale=bool(record.error) or bool(module == "leaderboard" and record.content and record.content["scope"] != "dailyOsmosisRank:" + now().astimezone(EASTERN).date().isoformat()) or bool(record.fetched_at and now() - record.fetched_at.replace(tzinfo=timezone.utc) > timedelta(hours=24)), stale=bool(record.error) or bool(module == "leaderboard" and record.content and record.content["scope"] != "dailyOsmosisRank:" + now().astimezone(EASTERN).date().isoformat()) or bool(record.fetched_at and now() - record.fetched_at.replace(tzinfo=timezone.utc) > timedelta(hours=24)),
source_version=record.version, analysis=analysis, source_version=record.version, analysis=analysis,
ephemeral=module == "messages",
can_generate=bool(config.description_model and config.api_key_encrypted and config.base_url and record.content)) can_generate=bool(config.description_model and config.api_key_encrypted and config.base_url and record.content))
@@ -364,7 +438,7 @@ async def generate(request: Request, module: Module, offset: int = Query(0, ge=0
raise HTTPException(409, "该模块正在生成解读,请稍候") raise HTTPException(409, "该模块正在生成解读,请稍候")
async with generation_lock: async with generation_lock:
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
record = await db.get(HomeInformation, (user, key)) record = await cached_record(request, db, user, module, offset, key)
config = await db.get(AISettings, 1) config = await db.get(AISettings, 1)
if not config.description_model or not config.api_key_encrypted or not config.base_url: if not config.description_model or not config.api_key_encrypted or not config.base_url:
raise HTTPException(409, "请先配置基础信息处理模型及共享连接配置") raise HTTPException(409, "请先配置基础信息处理模型及共享连接配置")
@@ -396,13 +470,17 @@ async def generate(request: Request, module: Module, offset: int = Query(0, ge=0
await ai.authorize(token_hash(request.cookies["wq_session"])) await ai.authorize(token_hash(request.cookies["wq_session"]))
if await identity(request) != user: if await identity(request) != user:
raise HTTPException(409, "账户已变化,请重新生成") raise HTTPException(409, "账户已变化,请重新生成")
async with lock(request, (user, key)): async with lock(request, (user, "messages" if module == "messages" else key)):
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
record = await db.get(HomeInformation, (user, key)) record = await cached_record(request, db, user, module, offset, key)
current_config = await db.get(AISettings, 1) current_config = await db.get(AISettings, 1)
if record is None:
raise HTTPException(409, "公告内存缓存已失效,请刷新后重新生成")
_, current_version = await analysis_sources(request, user, module, record) _, current_version = await analysis_sources(request, user, module, record)
if current_version != version or config_version(current_config) != fingerprint: if current_version != version or config_version(current_config) != fingerprint:
raise HTTPException(409, "生成期间来源或模型配置已变化,已保留上次解读,请重新生成") raise HTTPException(409, "生成期间来源或模型配置已变化,已保留上次解读,请重新生成")
record.analysis = dict(**interpretation, source_version=version, config_version=fingerprint, record.analysis = dict(**interpretation, source_version=version, config_version=fingerprint,
model=connection.model, generated_at=now().isoformat()) model=connection.model, generated_at=now().isoformat())
if module == "messages":
request.app.state.home_message_cache[user].analyses[offset] = record.analysis
return await output(request, user, module, record) return await output(request, user, module, record)
+1
View File
@@ -138,6 +138,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
app.state.settings = settings app.state.settings = settings
app.state.ai = ai_runtime app.state.ai = ai_runtime
app.state.home_information_locks = {} app.state.home_information_locks = {}
app.state.home_message_cache = {}
app.state.research = research_runtime app.state.research = research_runtime
app.state.mcp = mcp_runtime app.state.mcp = mcp_runtime
login_failures = defaultdict(list) login_failures = defaultdict(list)
+4 -2
View File
@@ -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)
+14 -7
View File
@@ -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,18 @@
"""Remove persisted announcement sources and interpretations; use memory only."""
import sqlalchemy as sa
from alembic import op
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade():
cache = sa.table("home_information", sa.column("resource", sa.String()))
op.execute(cache.delete().where(cache.c.resource.like("messages:%")))
def downgrade():
# Platform cache is reproducible; deleted text and interpretations are not restored.
pass
+97 -6
View File
@@ -35,6 +35,7 @@ async def setup(app, monkeypatch):
async with app.state.sessions.begin() as db: async with app.state.sessions.begin() as db:
account = await db.get(Account, 1) account = await db.get(Account, 1)
account.connection_status, account.wq_user_id = "connected", "TEST_USER" account.connection_status, account.wq_user_id = "connected", "TEST_USER"
monkeypatch.setattr(home, "now", lambda: datetime.fromisoformat("2026-09-12T12:00:00+00:00"))
calls, state = [], {"rank": 25, "fail": set(), "agreement": "Participants must use GLOBAL region. Delay must be 1."} calls, state = [], {"rank": 25, "fail": set(), "agreement": "Participants must use GLOBAL region. Delay must be 1."}
async def get(path, params=None, headers=None): async def get(path, params=None, headers=None):
@@ -43,7 +44,7 @@ async def setup(app, monkeypatch):
raise WqError("资源暂不可用") raise WqError("资源暂不可用")
if path == "/users/self/messages": if path == "/users/self/messages":
offset = params["offset"] offset = params["offset"]
return page([{"id": f"msg{offset}", "title": "Update", "description": "<p>News</p><script>secret()</script><a href='javascript:alert(1)'>bad</a>", "dateCreated": "2026-09-11T12:00:00-04:00"}], return page([{"id": f"msg{i}", "title": "Update", "description": "<p>News</p><script>secret()</script><a href='javascript:alert(1)'>bad</a>", "dateCreated": "2026-09-11T12:00:00-04:00"} for i in (range(10) if offset == 0 else [10])],
"https://api.worldquantbrain.com/users/self/messages?offset=1" if offset == 0 else None) "https://api.worldquantbrain.com/users/self/messages?offset=1" if offset == 0 else None)
if path == "/consultant/boards/leader": if path == "/consultant/boards/leader":
assert params == {"user": "TEST_USER"} assert params == {"user": "TEST_USER"}
@@ -64,12 +65,12 @@ async def setup(app, monkeypatch):
async def test_cache_pagination_failure_and_account_isolation(app, logged_in, monkeypatch): async def test_cache_pagination_failure_and_account_isolation(app, logged_in, monkeypatch):
calls, state = await setup(app, monkeypatch) calls, state = await setup(app, monkeypatch)
first = (await logged_in.get(PREFIX + "/messages")).json() first = (await logged_in.get(PREFIX + "/messages")).json()
assert first["content"]["next_offset"] == 1 assert first["content"]["next_offset"] == 10
assert "secret()" not in str(first) and "javascript:" not in str(first) assert "secret()" not in str(first) and "javascript:" not in str(first)
assert first["analysis"] is None and not first["can_generate"] assert first["analysis"] is None and not first["can_generate"]
assert (await logged_in.get(PREFIX + "/messages")).json() == first and len(calls) == 1 assert (await logged_in.get(PREFIX + "/messages")).json() == first and len(calls) == 2
second = (await logged_in.get(PREFIX + "/messages?offset=1")).json() second = (await logged_in.get(PREFIX + "/messages?offset=10")).json()
assert second["content"]["items"][0]["id"] == "msg1" assert second["content"]["items"][0]["id"] == "msg10"
state["fail"].add("/users/self/messages") state["fail"].add("/users/self/messages")
failed = (await logged_in.post(PREFIX + "/messages/refresh")).json() failed = (await logged_in.post(PREFIX + "/messages/refresh")).json()
assert failed["stale"] and failed["error"] assert failed["stale"] and failed["error"]
@@ -193,7 +194,7 @@ async def test_failed_grounding_preserves_prior_analysis(app, logged_in, monkeyp
async def test_concurrent_first_reads_are_coalesced(app, logged_in, monkeypatch): async def test_concurrent_first_reads_are_coalesced(app, logged_in, monkeypatch):
calls, _ = await setup(app, monkeypatch) calls, _ = await setup(app, monkeypatch)
results = await asyncio.gather(*[logged_in.get(PREFIX + "/messages") for _ in range(4)]) results = await asyncio.gather(*[logged_in.get(PREFIX + "/messages") for _ in range(4)])
assert all(r.status_code == 200 for r in results) and len(calls) == 1 assert all(r.status_code == 200 for r in results) and len(calls) == 2
async def test_event_sources_include_competitions_and_current_day(app, logged_in, monkeypatch): async def test_event_sources_include_competitions_and_current_day(app, logged_in, monkeypatch):
@@ -298,3 +299,93 @@ async def test_shared_connection_change_invalidates_ai(app, logged_in, monkeypat
setattr(await db.get(AISettings, 1), field, value) setattr(await db.get(AISettings, 1), field, value)
assert (await logged_in.get(PREFIX + '/competition' + QUERY)).json()['analysis']['outdated'] assert (await logged_in.get(PREFIX + '/competition' + QUERY)).json()['analysis']['outdated']
assert calls == ['basic-model'] assert calls == ['basic-model']
@pytest.mark.parametrize('at,expected', [
('2026-03-31T12:00:00-04:00', '2026-02-28T12:00:00-05:00'),
('2024-03-31T12:00:00-04:00', '2024-02-29T12:00:00-05:00'),
('2026-01-31T12:00:00-05:00', '2025-12-31T12:00:00-05:00'),
])
def test_recent_month_uses_eastern_calendar_month(monkeypatch, at, expected):
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat(at))
assert home.month_start().isoformat() == expected
async def test_recent_messages_scan_pages_filter_unknown_future_and_boundary(monkeypatch):
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-03-31T12:00:00-04:00'))
calls = []
class Client:
async def get(self, path, params):
calls.append(params)
if params['offset'] == 0:
return page([{'id': 'old', 'dateCreated': '2026-02-28T11:59:59-05:00'},
{'id': 'unknown'}, {'id': 'future', 'dateCreated': '2026-04-01T00:00:00Z'}],
'https://api.worldquantbrain.com/users/self/messages?offset=3&limit=100')
return page([{'id': 'boundary', 'dateCreated': '2026-02-28T12:00:00-05:00'},
{'id': 'today', 'dateCreated': '2026-03-31T12:00:00-04:00'}])
data = await home.source(Client(), 'messages', 'TEST_USER', 0, '')
assert [item['id'] for item in data['items']] == ['today', 'boundary']
assert calls == [{'offset': 0, 'limit': 100}, {'offset': 3, 'limit': 100}]
async def test_messages_and_ai_never_persist_and_expire_without_auto_generation(app, logged_in, monkeypatch):
platform_calls, _ = await setup(app, monkeypatch)
await model_setup(app)
model_calls = []
def respond(messages, info):
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, {'items': [], 'suggestions': []})])
@asynccontextmanager
async def factory(config, settings):
model_calls.append(config.model)
yield FunctionModel(respond)
app.state.ai.model_factory = factory
# Legacy rows must never be used; migration will remove them on deployment.
async with app.state.sessions.begin() as db:
db.add(HomeInformation(user_id='TEST_USER', resource='messages:0', content={'items': [{'title': 'legacy'}]}))
first = (await logged_in.get(PREFIX + '/messages')).json()
assert first['ephemeral'] and first['content']['total'] == 11
generated = await logged_in.post(PREFIX + '/messages/generate')
assert generated.status_code == 200, generated.text
assert generated.json()['analysis']['model'] == 'basic-model'
await logged_in.get(PREFIX + '/messages')
await logged_in.get(PREFIX + '/messages?offset=10')
assert len(platform_calls) == 2 and model_calls == ['basic-model']
async with app.state.sessions() as db:
saved = (await db.scalars(sa.select(HomeInformation))).all()
assert len(saved) == 1 and saved[0].content == {'items': [{'title': 'legacy'}]}
assert saved[0].analysis is None
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:15:00+00:00'))
assert (await logged_in.post(PREFIX + '/messages/generate')).status_code == 409
reloaded = (await logged_in.get(PREFIX + '/messages')).json()
assert reloaded['analysis'] is None and len(platform_calls) == 4
assert model_calls == ['basic-model']
async def test_message_window_rechecks_memory_and_removes_expired_interpretation(app, logged_in, monkeypatch):
await setup(app, monkeypatch)
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:00:00+00:00'))
async def upstream(path, params=None):
return page([{'id': 'boundary', 'dateCreated': '2026-08-12T08:00:00-04:00'}])
monkeypatch.setattr(app.state.runner.client, 'get', upstream)
assert (await logged_in.get(PREFIX + '/messages')).json()['content']['total'] == 1
app.state.home_message_cache['TEST_USER'].analyses[0] = {'old': 'interpretation'}
monkeypatch.setattr(home, 'now', lambda: datetime.fromisoformat('2026-09-12T12:00:01+00:00'))
result = (await logged_in.get(PREFIX + '/messages')).json()
assert result['content']['total'] == 0 and result['analysis'] is None
def test_message_cleanup_migration_only_removes_message_cache(tmp_path):
path = Path(__file__).parents[1] / 'migrations/versions/0017_remove_message_cache.py'
spec = importlib.util.spec_from_file_location('message_cleanup', path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
engine = sa.create_engine(f'sqlite:///{tmp_path}/cleanup.db')
with engine.begin() as conn:
conn.exec_driver_sql('CREATE TABLE home_information (user_id TEXT, resource TEXT, content TEXT, analysis TEXT)')
for user, resource in [('A', 'messages:0'), ('B', 'messages:10'), ('A', 'events:'), ('A', 'competition:ARC2026')]:
conn.exec_driver_sql('INSERT INTO home_information VALUES (?, ?, ?, ?)', (user, resource, 'source', 'ai'))
with Operations.context(MigrationContext.configure(conn)):
migration.upgrade()
migration.upgrade()
assert conn.exec_driver_sql('SELECT resource FROM home_information ORDER BY resource').scalars().all() == ['competition:ARC2026', 'events:']
engine.dispose()
+33
View File
@@ -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"
+56
View File
@@ -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"]
+58 -95
View File
@@ -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={() => {
+1
View File
@@ -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
View File
@@ -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>
+12 -5
View File
@@ -71,6 +71,7 @@ type Analysis = {
outdated: boolean; outdated: boolean;
}; };
type Snapshot = { type Snapshot = {
ephemeral?: boolean;
content: Content | null; content: Content | null;
fetched_at: string | null; fetched_at: string | null;
error: string | null; error: string | null;
@@ -243,7 +244,9 @@ function Interpretation({ state, zone }: { state: Resource; zone: string }) {
</> </>
) : ( ) : (
<p className="home-info-muted"> <p className="home-info-muted">
手动生成并保存,重新打开首页不会调用模型。 {state.data?.ephemeral
? "解读仅暂存在内存,不写入数据库;重启或缓存到期后清除。"
: "手动生成并保存,重新打开首页不会调用模型。"}
</p> </p>
)} )}
</div> </div>
@@ -348,7 +351,9 @@ function MessagePage({
state={state} state={state}
zone={zone} zone={zone}
> >
<p className="home-info-muted">来自平台消息的公告、更新及社区资讯。</p> <p className="home-info-muted">
仅展示最近一个月的公告、更新及社区资讯(按美东时间筛选),不写入数据库。
</p>
{content?.items?.length ? ( {content?.items?.length ? (
<div className="home-info-feed"> <div className="home-info-feed">
{content.items.map((item, index) => ( {content.items.map((item, index) => (
@@ -376,13 +381,15 @@ function MessagePage({
))} ))}
</div> </div>
) : ( ) : (
<Empty>本页暂无平台消息</Empty> <Empty>最近一个月本页暂无平台消息</Empty>
)} )}
<div className="home-info-row home-info-pagination"> <div className="home-info-row home-info-pagination">
<span className="home-info-muted"> <span className="home-info-muted">
从第 {offset + 1} 条开始 从第 {offset + 1} 条开始
{content?.total != null ? ` · 平台共 ${content.total} 条` : ""} · {content?.total != null
解读仅针对本页 ? ` · 最近一个月共 ${content.total} 条`
: ""}{" "}
· 解读仅针对本页
</span> </span>
<div className="home-info-actions"> <div className="home-info-actions">
<Button size="small" disabled={!back || !!state.busy} onClick={back}> <Button size="small" disabled={!back || !!state.busy} onClick={back}>
+195 -89
View File
@@ -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
+135 -78
View File
@@ -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}
+21 -2
View File
@@ -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
? { ? {
+2 -132
View File
@@ -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>
</>
}
/>
)}
</>
);
}
+23 -56
View File
@@ -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}
> >
核对并启动研究 核对并启动研究
+19 -102
View File
@@ -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)}
> >
保存候选研究记录 保存候选研究记录
+222
View File
@@ -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>
);
}
+198
View File
@@ -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;
}
+85
View File
@@ -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),
};
}
+9 -3
View File
@@ -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();
+174 -9
View File
@@ -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);
});
+15
View File
@@ -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=特征转换模板",
+264
View File
@@ -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,
});
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 修改这三个值即可。
REGION="EUR"
UNIVERSE="TOP2500"
DELAY="1"
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-wq-alpha-production}"
BACKEND_PYTHON="/app/.venv/bin/python"
if ! docker info >/dev/null; then
echo "ERROR: 无法连接 Docker,请检查 1Panel 任务的 Docker 访问权限" >&2
exit 1
fi
# 先检查 docker ps 的退出状态;进程替换会掩盖查询失败。
if ! CONTAINER_NAMES="$(
docker ps \
--filter "status=running" \
--filter "label=com.docker.compose.project=${PROJECT_NAME}" \
--filter "label=com.docker.compose.service=backend" \
--format '{{.Names}}'
)"; then
echo "ERROR: 查询 production backend 容器失败" >&2
exit 1
fi
BACKEND_CONTAINERS=()
while IFS= read -r container; do
if [ -n "$container" ]; then
BACKEND_CONTAINERS+=("$container")
fi
done <<< "$CONTAINER_NAMES"
if [ "${#BACKEND_CONTAINERS[@]}" -eq 0 ]; then
echo "ERROR: 没有找到 production backend 容器,Compose 项目名:${PROJECT_NAME}" >&2
exit 1
fi
if [ "${#BACKEND_CONTAINERS[@]}" -gt 1 ]; then
echo "ERROR: 找到多个 production backend 容器,拒绝选择:" >&2
printf ' %s\n' "${BACKEND_CONTAINERS[@]}" >&2
exit 1
fi
BACKEND_CONTAINER="${BACKEND_CONTAINERS[0]}"
HEALTH_STATUS="$(
docker inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
"$BACKEND_CONTAINER"
)"
if [ "$HEALTH_STATUS" != "healthy" ]; then
echo "ERROR: backend 容器当前不健康,状态:${HEALTH_STATUS}" >&2
exit 1
fi
if ! docker exec "$BACKEND_CONTAINER" test -x "$BACKEND_PYTHON"; then
echo "ERROR: 容器中不存在可执行命令:${BACKEND_PYTHON}" >&2
exit 1
fi
if ! docker exec --workdir /app "$BACKEND_CONTAINER" \
"$BACKEND_PYTHON" -m app.cli catalog-sync --help >/dev/null; then
echo "ERROR: catalog-sync CLI 不可用,请确认已部署包含全量目录同步的镜像" >&2
exit 1
fi
if ! docker exec "$BACKEND_CONTAINER" sh -c \
'test -n "${DATABASE_URL:-}" && test -n "${ENCRYPTION_KEY:-}" && test -n "${ADMIN_PASSWORD:-}"'; then
echo "ERROR: 容器缺少 DATABASE_URL、ENCRYPTION_KEY 或 ADMIN_PASSWORD" >&2
exit 1
fi
echo "使用容器:${BACKEND_CONTAINER}"
echo "开始全量同步:${REGION} / ${UNIVERSE} / Delay ${DELAY}"
# 沿用容器环境和现有后台执行器,不创建另一套同步进程。
# 保留 CLI 退出码;等待超时不取消后台任务。
exec docker exec --workdir /app \
"$BACKEND_CONTAINER" \
"$BACKEND_PYTHON" -u -m app.cli catalog-sync \
--region "$REGION" --universe "$UNIVERSE" --delay "$DELAY"