feat: persist feature research and versioned alpha assessments

This commit is contained in:
yuxuanhui
2026-09-08 21:52:46 +08:00
parent f89ae211d2
commit eb4850a003
32 changed files with 2003 additions and 20 deletions
@@ -0,0 +1,36 @@
# 第二阶段验收:特征与成果管理
日期:2026-09-08。范围:特征方案、保存视图、变体关系、版本化评估。未调用真实模型或 WorldQuant;平台调用使用隔离合成响应。
## 交付行为
- 特征方案保存固定输入快照、经济假设、处理步骤及输出模板,支持历史版本、AI 结构化生成和转换模板;转换保存完整方案版本,并在模板工坊恢复输入和假设。
- Alpha 保存视图持久化筛选、排序和显示列,支持搜索恢复、新建及乐观版本更新。
- 实验父来源有可查询关系索引,迁移补齐已有 JSON 父来源。关系图保留多父来源,沿实验、回测产出 Alpha、后续种子研究双向追溯。每次回测来源仍沿用原分页来源接口。
- 规则评估保存规则版本和阈值、平台快照、实验输入、回测条目设置、缺失项及本地自相关证据。模型建议另存新报告,不覆盖规则判断和旧报告;不自动修改 Alpha 本地研究状态。
- Alpha 详情及实验详情共用评估、来源关系能力;AI 共用特征保存、转换及规则评估业务接口。
## 验收步骤与结果
| 验收 | 结果与证据 |
|---|---|
| 特征保存 v1、转换模板、更新 v2、展开旧模板 | 通过;实验仍保留原方案 v1 的假设、输入和步骤 |
| 保存筛选、排序、列设置;提交非法排序 | 通过;视图恢复原设置,非法参数返回 422 |
| 缺失指标、缺失检查、已有 FAIL、完整 PASS | 通过;分别保留 review/block/pass,缺失不当零 |
| 同步覆盖平台快照、模型建议“通过” | 通过;旧报告快照不变,规则 block 不变 |
| 部分回测结果 | 通过;不完整结果即使指标达标也保留待核实,实验输入和设置快照存在 |
| 多父实验、孙实验、产出 Alpha 再研究 | 通过;从种子或后续实验均可找到来源链路 |
| 后端全量回归 | 187 passed;之后补充的不完整结果及跨 Alpha 追溯等专项 10 passed。日志 `/tmp/wq-stage-two-full-backend.log`、`/tmp/wq-stage-two-focused.log` |
| 前端类型检查及生产构建 | 通过;已有 lottie-web eval 构建警告不影响构建。日志 `/tmp/wq-stage-two-build-final.log` |
| 浏览器特征编辑→保存→转模板→保存视图→导入合成 Alpha→保存评估 | 通过;1 passed,页面无 JS 错误。已检查截图 `/tmp/wq-stage-two-evaluation.png` |
| 现有浏览器回归 | 全量初跑 16 通过、2 失败;修正合成数据定位及异步任务等待后,失败场景与相邻工作空间复验 3 passed。原失败均已覆盖。日志 `/tmp/wq-stage-two-browser-regression.log`、`/tmp/wq-stage-two-browser-followup.log` |
| PostgreSQL 17 增量迁移 0006→0007 | 通过;Alembic check 无额外操作,旧研究备注保留,版本、关系和评估 API 通过 |
| PostgreSQL 备份恢复 | 通过;独立测试库 pg_dump/pg_restore 后研究版本、评估、关系数量和旧备注一致。脚本 `backend/tests/research_outcomes_postgres.py`,日志 `/tmp/wq-stage-two-postgres.log` |
浏览器首轮发现的是验收定位与合成测试参数问题:固定输入使用既有的 all/excluded_ids 协议;保留草稿的隐藏页不能用全局标签定位;Semi 弹窗确认按钮的无障碍名称为 confirm。修正后新增闭环通过。全量回归另发现共享合成数据使 Alpha 不在第一页,以及旧数据数量提前满足断言造成任务浮层晚打开;改为筛选目标 Alpha、等待对应任务完成及浮层出现,相关场景复验通过。
## 边界
关系图响应限制 100 个实验、8 层;截断时明确提示并提供边界继续展开。每条原始回测来源仍可分页完整读取。评估模型解释一次最多使用 20 条规则记录,保存选取范围,不改变完整规则报告。特征步骤不执行原始时间序列离线计算。
真实 WorldQuant 元数据与实际模拟仍因自动审批要求当前对话明确授权而未验证;此前已发出授权问题,不以合成平台测试代替真实联调。
@@ -5,7 +5,7 @@ Status: ready-for-agent
## 阶段
- [x] 一:算子、模板、表达式模块、两类变体与回测来源闭环。
- [ ] 二:特征方案、保存视图、关系、比较和评估。
- [x] 二:特征方案、保存视图、关系、比较和评估。
- [ ] 三:固定自动研究、预算、持久步骤、恢复。
- [ ] 四:原生 QuantFlow 画布与共用执行。
- [ ] 验证:后端、前端、浏览器、PostgreSQL 迁移与恢复。
@@ -15,3 +15,5 @@ Status: ready-for-agent
开始实现。工作区原有侧栏、配置抽屉、Alpha 页面及测试改动保留;主代理负责所有代码修改和最终验证。
第一阶段已通过本地后端、浏览器和 PostgreSQL 验收;报告见 `../acceptance/stage-1.md`。真实 WorldQuant 联调因自动审批未认可已有授权而等待当前对话确认,不将合成测试记为真实联调。
第二阶段已通过后端、前端、浏览器和 PostgreSQL 迁移/恢复验证;报告见 `../acceptance/stage-2.md`。独立 Alpha 列表布局改动继续留在工作区,本次仅提交保存视图接入。
+1 -1
View File
@@ -36,7 +36,7 @@ class ModelSettingsInput(Contract):
class PageContext(Contract):
page: Literal["alphas", "account", "datasets", "backtests", "operators", "templates", "variants"] = "alphas"
page: Literal["alphas", "account", "datasets", "backtests", "operators", "templates", "features", "variants"] = "alphas"
research_asset_id: str | None = Field(default=None, max_length=36)
research_experiment_id: str | None = Field(default=None, max_length=36)
catalog_scope: Scope | None = None
+22
View File
@@ -348,6 +348,7 @@ class CatalogDataset(Base):
class CatalogEntry(Base):
"""Immutable published snapshots; staging rows remain invisible until batch completion."""
__tablename__ = "catalog_entries"
batch_id: Mapped[str] = mapped_column(ForeignKey("catalog_batches.id"), primary_key=True)
id: Mapped[str] = mapped_column(String(200), primary_key=True)
@@ -389,6 +390,7 @@ class TemplateInput(Base):
class CatalogResource(Base):
"""Read-only upstream metadata snapshots; local annotations live separately."""
__tablename__ = "catalog_resources"
key: Mapped[str] = mapped_column(String(250), primary_key=True)
kind: Mapped[str] = mapped_column(String(30), index=True)
@@ -406,6 +408,7 @@ class OperatorNote(Base):
class ResearchAsset(Base):
"""Stable identity for typed templates, feature plans, views and workflow definitions."""
__tablename__ = "research_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
kind: Mapped[str] = mapped_column(String(30), index=True)
@@ -426,6 +429,7 @@ class ResearchRevision(Base):
class ResearchExperiment(Base):
"""Immutable generated candidates; never masquerade as platform Alpha records."""
__tablename__ = "research_experiments"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
name: Mapped[str] = mapped_column(String(200))
@@ -436,3 +440,21 @@ class ResearchExperiment(Base):
candidates: Mapped[list] = mapped_column(JSON)
evidence: Mapped[dict] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class ResearchEvaluation(Base):
__tablename__ = "research_evaluations"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
alpha_id: Mapped[str | None] = mapped_column(ForeignKey("alphas.id"), index=True)
experiment_id: Mapped[str | None] = mapped_column(ForeignKey("research_experiments.id"), index=True)
report: Mapped[dict] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class ResearchParent(Base):
"""Queryable edges; parent snapshots remain authoritative in the experiment."""
__tablename__ = "research_parents"
child_id: Mapped[str] = mapped_column(ForeignKey("research_experiments.id"), primary_key=True)
parent_kind: Mapped[str] = mapped_column(String(30), primary_key=True)
parent_id: Mapped[str] = mapped_column(String(100), primary_key=True, index=True)
+8 -3
View File
@@ -65,8 +65,8 @@ class Assets:
if body.kind == "feature":
from ..catalog.service import Catalog
for input_id in content["input_ids"]:
await Catalog(self.db).input(input_id)
snapshots = [await Catalog(self.db).input(input_id) for input_id in content["input_ids"]]
provenance = {**(provenance or {}), "inputs": snapshots}
if asset_id:
if body.version is None:
raise HTTPException(422, "更新需要素材版本")
@@ -87,7 +87,12 @@ class Assets:
self.db.add(ResearchAsset(id=asset_id, kind=body.kind, name=content["name"], version=version))
await self.db.flush()
self.db.add(
ResearchRevision(asset_id=asset_id, version=version, content=content, provenance=provenance or {})
ResearchRevision(
asset_id=asset_id,
version=version,
content=content,
provenance=jsonable_encoder(provenance or {}),
)
)
await self.db.flush()
return await self.get(asset_id, version)
+170
View File
@@ -0,0 +1,170 @@
"""Snapshot-based research assessment. Model advice cannot replace deterministic findings."""
import math
from fastapi import HTTPException
from sqlalchemy import select
from ..backtests.service import Backtests, uid
from ..models import Alpha, ResearchEvaluation, ResearchExperiment, SelfCorrelation
from .experiments import Experiments
from .serialization import encode_snapshot as jsonable_encoder
def assess(snapshot, rules):
metrics = snapshot.get("is") or {}
evidence, missing, failed = [], [], []
for key, bound, direction in (
("sharpe", rules.sharpe_min, "min"),
("fitness", rules.fitness_min, "min"),
("turnover", rules.turnover_max, "max"),
):
value = metrics.get(key)
if type(value) not in (int, float) or not math.isfinite(value):
missing.append(key)
value, status = None, "unknown"
else:
status = "pass" if (value >= bound if direction == "min" else value <= bound) else "block"
if status == "block":
failed.append(key)
evidence.append(
{"metric": key, "value": value, "bound": bound, "direction": direction, "status": status}
)
checks = metrics.get("checks") or []
if not checks:
missing.append("platform_checks")
for check in checks:
if check.get("result") == "FAIL":
failed.append(f"platform:{check.get('name', 'unknown')}")
unknown_checks = [check for check in checks if check.get("result") not in ("PASS", "FAIL")]
if unknown_checks:
missing.append("unresolved_platform_checks")
return {
"verdict": "block" if failed else "review" if missing else "pass",
"evidence": evidence,
"failed": failed,
"missing": missing,
"existing_platform_checks": checks,
"meaning": "本地研究筛选结果,不是官方提交资格",
}
class Evaluations:
def __init__(self, db):
self.db = db
async def create(self, body):
records = []
if body.alpha_id:
alpha = await self.db.get(Alpha, body.alpha_id)
if not alpha:
raise HTTPException(404, "Alpha 尚未同步")
records.append(
{
"alpha_id": alpha.id,
"snapshot": alpha.raw,
"observed_at": alpha.synced_at,
"client_item_id": None,
}
)
else:
run = await Backtests(self.db).run(body.backtest_run_id)
if body.experiment_id and run["source"].get("research_id") != body.experiment_id:
raise HTTPException(422, "回测运行不属于指定研究实验")
for offset in range(0, run["total"], 100):
page = await Backtests(self.db).results(body.backtest_run_id, 100, offset)
for item in page["items"]:
records.append(
{
"alpha_id": item["alpha_id"],
"snapshot": item["result"]["snapshot"] if item["result"] else {},
"observed_at": item["result"]["observed_at"] if item["result"] else None,
"client_item_id": item["client_item_id"],
"item_id": item["id"],
"error": item["error"],
"complete": bool(item["result"] and item["result"]["complete"]),
"expression": item["expression"],
"settings": item["settings"],
}
)
if body.experiment_id and not await self.db.get(ResearchExperiment, body.experiment_id):
raise HTTPException(404, "研究实验不存在")
findings = []
for record in records:
correlation = (
await self.db.get(SelfCorrelation, record["alpha_id"]) if record["alpha_id"] else None
)
finding = assess(record["snapshot"], body.rules)
if record.get("error") or record.get("complete") is False:
finding["missing"].append("backtest_error")
if finding["verdict"] == "pass":
finding["verdict"] = "review"
findings.append(
{
**record,
**finding,
"local_correlation": {
"result": correlation.result,
"stale": correlation.stale,
"calculated_at": correlation.calculated_at,
}
if correlation
else None,
}
)
report = jsonable_encoder(
{
"rules": body.rules.model_dump(),
"records": findings,
"backtest_run_id": body.backtest_run_id,
"experiment": await Experiments(self.db).get(body.experiment_id)
if body.experiment_id
else None,
"model_advice": None,
"verdict": "block"
if any(r["verdict"] == "block" for r in findings)
else "review"
if not findings or any(r["verdict"] == "review" for r in findings)
else "pass",
}
)
row = ResearchEvaluation(
id=uid(), alpha_id=body.alpha_id, experiment_id=body.experiment_id, report=report
)
self.db.add(row)
await self.db.flush()
return await self.get(row.id)
async def get(self, evaluation_id):
row = await self.db.get(ResearchEvaluation, evaluation_id)
if not row:
raise HTTPException(404, "评估报告不存在")
return jsonable_encoder(
{key: getattr(row, key) for key in ("id", "alpha_id", "experiment_id", "report", "created_at")}
)
async def list(self, alpha_id=None, experiment_id=None, limit=25, offset=0):
query = select(ResearchEvaluation)
if alpha_id:
query = query.where(ResearchEvaluation.alpha_id == alpha_id)
if experiment_id:
query = query.where(ResearchEvaluation.experiment_id == experiment_id)
rows = await self.db.scalars(
query.order_by(ResearchEvaluation.created_at.desc()).limit(limit).offset(offset)
)
return {"items": [await self.get(row.id) for row in rows]}
async def add_advice(self, evaluation_id, advice, model_evidence):
original = await self.get(evaluation_id)
report = {
**original["report"],
"model_advice": advice,
"model_evidence": model_evidence,
"previous_evaluation_id": evaluation_id,
}
row = ResearchEvaluation(
id=uid(), alpha_id=original["alpha_id"], experiment_id=original["experiment_id"], report=report
)
self.db.add(row)
await self.db.flush()
return await self.get(row.id)
+5
View File
@@ -204,6 +204,11 @@ class Experiments:
)
self.db.add(row)
await self.db.flush()
from ..models import ResearchParent
for parent_kind, parent_id in {(p["kind"], p["id"]) for p in parents}:
self.db.add(ResearchParent(child_id=row.id, parent_kind=parent_kind, parent_id=parent_id))
await self.db.flush()
return await self.get(row.id)
async def get(self, experiment_id):
+20
View File
@@ -0,0 +1,20 @@
"""Feature plans remain versioned inputs to the shared template research path."""
from fastapi import HTTPException
from .assets import Assets
from .workspace_contracts import AssetWrite
class Features:
def __init__(self, db):
self.assets = Assets(db)
async def to_template(self, asset_id, version):
feature = await self.assets.get(asset_id, version, "feature")
if not feature["content"].get("template"):
raise HTTPException(422, "特征方案尚未定义输出模板")
return await self.assets.save(
AssetWrite(kind="template", content=feature["content"]["template"]),
provenance={"feature": feature},
)
+113
View File
@@ -0,0 +1,113 @@
"""Bounded graph traversal with explicit continuation, retaining every result source."""
from fastapi import HTTPException
from sqlalchemy import or_, select
from ..models import Alpha, BacktestResult, BacktestRun, ResearchExperiment, ResearchParent
from .experiments import Experiments
from .provenance import alpha_sources, saved_sources
async def lineage(db, alpha_id=None, experiment_id=None, limit=25, offset=0):
if bool(alpha_id) == bool(experiment_id):
raise HTTPException(422, "指定 Alpha 或实验之一")
sources = None
frontier = set()
truncated = False
if alpha_id:
if not await db.get(Alpha, alpha_id):
raise HTTPException(404, "Alpha 尚未同步")
sources = await alpha_sources(db, alpha_id, limit, offset)
source_ids = list(
await db.scalars(
saved_sources()
.with_only_columns(BacktestRun.source["research_id"].as_string())
.where(BacktestResult.alpha_id == alpha_id)
.distinct()
.limit(101)
)
)
children = list(
await db.scalars(
select(ResearchParent.child_id)
.where(ResearchParent.parent_kind == "alpha", ResearchParent.parent_id == alpha_id)
.order_by(ResearchParent.child_id)
.limit(101)
)
)
frontier = set(source_ids + children) - {None}
truncated = len(frontier) > 100
else:
await Experiments(db).get(experiment_id)
frontier.add(experiment_id)
found, edges = {}, {}
for _ in range(8):
wanted = sorted(frontier - set(found))
if not wanted:
break
remaining = 100 - len(found)
if len(wanted) > remaining:
truncated = True
wanted = wanted[:remaining]
if not wanted:
break
rows = list(await db.scalars(select(ResearchExperiment).where(ResearchExperiment.id.in_(wanted))))
for row in rows:
found[row.id] = await Experiments(db).get(row.id)
produced = list(
await db.scalars(
saved_sources()
.with_only_columns(BacktestResult.alpha_id)
.where(BacktestRun.source["research_id"].as_string().in_(wanted))
.distinct()
.limit(1001)
)
)
truncated |= len(produced) > 1000
parent_alphas = {p["id"] for row in rows for p in row.parents if p["kind"] == "alpha"}
related_sources = list(
await db.scalars(
saved_sources()
.with_only_columns(BacktestRun.source["research_id"].as_string())
.where(BacktestResult.alpha_id.in_(parent_alphas | set(produced[:1000])))
.distinct()
.limit(101)
)
)
truncated |= len(related_sources) > 100
relations = list(
await db.scalars(
select(ResearchParent)
.where(
or_(
ResearchParent.child_id.in_(wanted),
(ResearchParent.parent_kind == "alpha")
& ResearchParent.parent_id.in_(produced[:1000]),
(ResearchParent.parent_kind == "experiment") & ResearchParent.parent_id.in_(wanted),
)
)
.order_by(ResearchParent.child_id, ResearchParent.parent_kind, ResearchParent.parent_id)
.limit(1001)
)
)
truncated |= len(relations) > 1000
frontier = set(related_sources[:100]) - {None}
for edge in relations[:1000]:
edges[(edge.child_id, edge.parent_kind, edge.parent_id)] = {
"child_id": edge.child_id,
"parent_kind": edge.parent_kind,
"parent_id": edge.parent_id,
}
frontier.add(edge.child_id)
if edge.parent_kind == "experiment":
frontier.add(edge.parent_id)
unresolved = sorted(frontier - set(found))
return {
"items": list(found.values()),
"edges": list(edges.values()),
"sources": sources,
"truncated": truncated or bool(unresolved),
"unresolved_experiment_ids": unresolved,
"limit": 100,
"max_depth": 8,
}
+84 -8
View File
@@ -5,13 +5,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..security import require_auth
from .assets import Assets
from .comparisons import compare
from .evaluations import Evaluations
from .experiments import Experiments
from .model import request_model
from .features import Features
from .model import OUTPUTS, Advice, request_model
from .workspace_contracts import (
AssetWrite,
CompareInput,
EvaluateInput,
Expansion,
ExperimentPreview,
FeatureConversion,
Generation,
ImportCommit,
ImportPreview,
@@ -35,7 +39,7 @@ async def assets(
limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0),
):
if kind != "template":
if kind not in ("template", "feature", "view"):
raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions() as db:
return await Assets(db).list(kind, q, limit, offset)
@@ -43,7 +47,7 @@ async def assets(
@router.post("/assets", status_code=201)
async def save_asset(body: AssetWrite, request: Request):
if body.kind != "template":
if body.kind not in ("template", "feature", "view"):
raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions.begin() as db:
return await Assets(db).save(body)
@@ -57,7 +61,7 @@ async def asset(asset_id: str, request: Request, version: int | None = Query(Non
@router.put("/assets/{asset_id}")
async def update_asset(asset_id: str, body: AssetWrite, request: Request):
if body.kind != "template":
if body.kind not in ("template", "feature", "view"):
raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions.begin() as db:
return await Assets(db).save(body, asset_id)
@@ -89,14 +93,17 @@ async def import_commit(body: ImportCommit, request: Request):
@router.post("/generate", status_code=201)
async def generate(body: Generation, request: Request):
if body.method == "feature":
raise HTTPException(422, "特征方案生成将在特征工程阶段开放")
async with request.app.state.sessions() as db:
context = await Experiments(db).generation_context(body)
result, evidence = await request_model(request.app.state.ai, context)
result, evidence = await request_model(request.app.state.ai, context, OUTPUTS[body.method])
if body.method == "feature" and set(result.input_ids) != set(body.input_ids):
raise HTTPException(422, "模型不能改变已固定的输入范围")
async with request.app.state.sessions.begin() as db:
asset = await Assets(db).save(
AssetWrite(kind="template", content=result.model_dump(mode="json")),
AssetWrite(
kind="feature" if body.method == "feature" else "template",
content=result.model_dump(mode="json"),
),
provenance={"generation": evidence, "context": context},
)
return {
@@ -149,3 +156,72 @@ async def settings_variants(body: SettingVariants, request: Request):
async def comparison(body: CompareInput, request: Request):
async with request.app.state.sessions() as db:
return await compare(db, body.alpha_ids)
@router.post("/features/{asset_id}/template", status_code=201)
async def feature_template(asset_id: str, body: FeatureConversion, request: Request):
async with request.app.state.sessions.begin() as db:
return await Features(db).to_template(asset_id, body.version)
@router.post("/evaluations", status_code=201)
async def evaluate(body: EvaluateInput, request: Request):
async with request.app.state.sessions.begin() as db:
return await Evaluations(db).create(body)
@router.get("/evaluations")
async def evaluations(
request: Request,
alpha_id: str | None = None,
experiment_id: str | None = None,
limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0),
):
async with request.app.state.sessions() as db:
return await Evaluations(db).list(alpha_id, experiment_id, limit, offset)
@router.get("/evaluations/{evaluation_id}")
async def evaluation(evaluation_id: str, request: Request):
async with request.app.state.sessions() as db:
return await Evaluations(db).get(evaluation_id)
@router.post("/evaluations/{evaluation_id}/advice", status_code=201)
async def evaluation_advice(evaluation_id: str, request: Request):
async with request.app.state.sessions() as db:
original = await Evaluations(db).get(evaluation_id)
report = original["report"]
context = {
"evaluation_id": evaluation_id,
"rules": report["rules"],
"verdict": report["verdict"],
"total_records": len(report["records"]),
"records": [
{
key: row.get(key)
for key in ("alpha_id", "client_item_id", "evidence", "missing", "failed", "verdict")
}
for row in report["records"][:20]
],
"record_limit": 20,
}
result, evidence = await request_model(request.app.state.ai, context, Advice)
evidence["context"] = context
async with request.app.state.sessions.begin() as db:
return await Evaluations(db).add_advice(evaluation_id, result.model_dump(mode="json"), evidence)
@router.get("/lineage")
async def research_lineage(
request: Request,
alpha_id: str | None = None,
experiment_id: str | None = None,
limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0),
):
from .lineage import lineage
async with request.app.state.sessions() as db:
return await lineage(db, alpha_id, experiment_id, limit, offset)
+7 -2
View File
@@ -75,6 +75,10 @@ class FeatureSpec(Contract):
template: TemplateSpec | None = None
class FeatureConversion(Contract):
version: int = Field(ge=1)
class ViewSpec(Contract):
name: str = Field(min_length=1, max_length=200)
filters: dict = Field(default_factory=dict)
@@ -85,8 +89,7 @@ class ViewSpec(Contract):
def valid_filters(cls, value):
from ..schemas import AlphaFilters
AlphaFilters.model_validate(value)
return value
return AlphaFilters.model_validate(value).model_dump(mode="json", exclude_none=True)
class AssetWrite(Contract):
@@ -151,6 +154,8 @@ class EvaluateInput(Contract):
@model_validator(mode="after")
def target(self):
if self.alpha_id and self.experiment_id:
raise ValueError("实验评估应选择关联回测运行;Alpha 评估单独保存")
if bool(self.alpha_id) == bool(self.backtest_run_id):
raise ValueError("选择 Alpha 或回测运行之一")
return self
+56 -1
View File
@@ -6,8 +6,10 @@ from ..ai.capabilities import Capability
from ..catalog.research_metadata import ResearchMetadata
from ..schemas import Contract
from .assets import Assets
from .evaluations import Evaluations
from .experiments import Experiments
from .workspace_contracts import Expansion, SettingVariants
from .features import Features
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, FeatureSpec, SettingVariants
class AssetQuery(Contract):
@@ -21,6 +23,16 @@ class AssetReference(Contract):
version: int | None = Field(default=None, ge=1)
class FixedAssetReference(AssetReference):
version: int = Field(ge=1)
class FeatureWrite(Contract):
content: FeatureSpec
asset_id: str | None = Field(default=None, max_length=36)
version: int | None = Field(default=None, ge=1)
class ExperimentReference(Contract):
experiment_id: str = Field(min_length=1, max_length=36)
@@ -105,3 +117,46 @@ CAPABILITIES = (
),
),
)
CAPABILITIES += (
Capability(
name="search_feature_plans",
schema=AssetQuery,
description="搜索版本化特征方案。",
label="搜索特征方案",
renderer="research",
effect="query",
handler=lambda ctx, args: Assets(ctx.business.db).list("feature", **args.model_dump()),
),
Capability(
name="save_feature_plan",
schema=FeatureWrite,
description="保存固定输入、处理步骤和经济假设,不执行计算或回测。",
label="保存特征方案",
renderer="research",
effect="prepare",
handler=lambda ctx, args: Assets(ctx.business.db).save(
AssetWrite(kind="feature", content=args.content.model_dump(mode="json"), version=args.version),
args.asset_id,
),
),
Capability(
name="feature_to_template",
schema=FixedAssetReference,
description="将指定特征方案版本转为新模板,保留来源。",
label="特征转为模板",
renderer="research",
effect="prepare",
handler=lambda ctx, args: Features(ctx.business.db).to_template(args.asset_id, args.version),
),
Capability(
name="evaluate_research",
schema=EvaluateInput,
description="保存版本化规则评估,缺失数据不会作为通过,不修改研究状态。",
label="保存规则评估",
renderer="research",
effect="prepare",
handler=lambda ctx, args: Evaluations(ctx.business.db).create(args),
),
)
@@ -0,0 +1,42 @@
"""Stage two: immutable evaluations; features and saved views reuse asset revisions."""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade():
edges = op.create_table(
"research_parents",
sa.Column("child_id", sa.String(36), sa.ForeignKey("research_experiments.id"), primary_key=True),
sa.Column("parent_kind", sa.String(30), primary_key=True),
sa.Column("parent_id", sa.String(100), primary_key=True),
)
op.create_index("ix_research_parents_parent_id", "research_parents", ["parent_id"])
experiments = sa.table("research_experiments", sa.column("id"), sa.column("parents", sa.JSON()))
for row in op.get_bind().execute(sa.select(experiments)).mappings():
values = [
{"child_id": row["id"], "parent_kind": kind, "parent_id": parent_id}
for kind, parent_id in {(p["kind"], p["id"]) for p in row["parents"]}
]
if values:
op.bulk_insert(edges, values)
op.create_table(
"research_evaluations",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("alpha_id", sa.String(100), sa.ForeignKey("alphas.id"), nullable=True),
sa.Column("experiment_id", sa.String(36), sa.ForeignKey("research_experiments.id"), nullable=True),
sa.Column("report", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_research_evaluations_alpha_id", "research_evaluations", ["alpha_id"])
op.create_index("ix_research_evaluations_experiment_id", "research_evaluations", ["experiment_id"])
def downgrade():
op.drop_table("research_evaluations")
op.drop_table("research_parents")
@@ -0,0 +1,80 @@
"""Stage-two PostgreSQL acceptance in dedicated databases only."""
import asyncio
import os
import subprocess
from pathlib import Path
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
NAME = "wq_research_stage2_test"
RESTORE = "wq_research_restore_stage2"
os.environ.update(
DATABASE_URL=f"postgresql+asyncpg://postgres:research-test-only@127.0.0.1:18436/{NAME}",
ADMIN_PASSWORD="research-acceptance-only",
ENCRYPTION_KEY=Fernet.generate_key().decode(),
)
def docker(*args, **kwargs):
return subprocess.run(["docker", "exec", "-i", "wq-research-acceptance-pg", *args], check=True, **kwargs)
async def acceptance():
import httpx
from sqlalchemy import select
from app.config import Settings
from app.main import create_app
from app.models import ResearchExperiment, ResearchParent, TemplateInput
from tests.test_research_outcomes import (
test_feature_conversion_keeps_original_version_through_experiment,
test_lineage_retains_multiple_parents_and_descendants,
test_saved_views_validate_and_retain_sort_columns,
test_sync_and_model_advice_do_not_rewrite_report,
)
app = create_app(Settings(_env_file=None, enable_runner=False, public_origin="http://testserver"))
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app), base_url="http://testserver", headers={"X-WQ-Request": "1"}
) as client:
response = await client.post(
"/api/v1/auth/login", json={"username": "admin", "password": "research-acceptance-only"}
)
assert response.status_code == 200
async with app.state.sessions() as db:
fixed = await db.scalar(select(TemplateInput))
for experiment in await db.scalars(select(ResearchExperiment)):
for parent in experiment.parents:
assert await db.get(ResearchParent, (experiment.id, parent["kind"], parent["id"]))
await test_feature_conversion_keeps_original_version_through_experiment(client, {"id": fixed.id})
await test_saved_views_validate_and_retain_sort_columns(client)
await test_sync_and_model_advice_do_not_rewrite_report(app, client)
await test_lineage_retains_multiple_parents_and_descendants(app, client, {"id": fixed.id})
print("PASS PostgreSQL: feature revisions, saved views, immutable evaluations and multi-parent traversal")
if __name__ == "__main__":
docker("createdb", "-U", "postgres", NAME)
with Path("/tmp/wq-research-stage1.dump").open("rb") as source:
docker("pg_restore", "-U", "postgres", "-d", NAME, stdin=source)
config = Config("alembic.ini")
command.upgrade(config, "0007")
command.check(config)
asyncio.run(acceptance())
dump = Path("/tmp/wq-research-stage2.dump")
with dump.open("wb") as output:
docker("pg_dump", "-U", "postgres", "-Fc", NAME, stdout=output)
docker("createdb", "-U", "postgres", RESTORE)
with dump.open("rb") as source:
docker("pg_restore", "-U", "postgres", "-d", RESTORE, stdin=source)
query = "SELECT (SELECT count(*) FROM research_revisions),(SELECT count(*) FROM research_evaluations),(SELECT count(*) FROM research_parents),(SELECT note FROM research WHERE alpha_id='OLD_RESEARCH')"
a = docker("psql", "-U", "postgres", "-d", NAME, "-Atc", query, capture_output=True).stdout
b = docker("psql", "-U", "postgres", "-d", RESTORE, "-Atc", query, capture_output=True).stdout
assert a == b
print(
"PASS PostgreSQL 17: 0006 → 0007 and pg_dump/pg_restore preserve history, graph and old research notes"
)
+191
View File
@@ -0,0 +1,191 @@
"""Stage-two acceptance: immutable plans, views, lineage and evidence-backed decisions."""
import pytest
from app.alphas import upsert_alpha
from app.models import Research
from tests.conftest import alpha
from tests.test_research_workspace import catalog, expansion, research_input, template
__all__ = ["catalog", "research_input"]
async def test_feature_conversion_keeps_original_version_through_experiment(logged_in, research_input):
content = {
"name": "特征方案",
"hypothesis": "排序稳定性",
"input_ids": [research_input["id"]],
"steps": [{"name": "排序", "rationale": "降低异常值影响", "expression": "rank(TEST_FIN_001)"}],
"template": template(),
}
saved = (
await logged_in.post("/api/v1/research/assets", json={"kind": "feature", "content": content})
).json()
converted = await logged_in.post(f"/api/v1/research/features/{saved['id']}/template", json={"version": 1})
assert converted.status_code == 201, converted.text
result = converted.json()
changed = await logged_in.put(
f"/api/v1/research/assets/{saved['id']}",
json={"kind": "feature", "version": 1, "content": {**content, "hypothesis": "新假设"}},
)
assert changed.status_code == 200
body = expansion(research_input["id"], asset_id=result["id"], version=1)
body.pop("template")
experiment = (await logged_in.post("/api/v1/research/experiments", json=body)).json()
source = experiment["evidence"]["template"]["provenance"]["feature"]
assert source["version"] == 1 and source["content"]["hypothesis"] == "排序稳定性"
assert source["content"]["input_ids"] == [research_input["id"]]
async def test_saved_views_validate_and_retain_sort_columns(logged_in):
content = {
"name": "低换手队列",
"filters": {
"turnover_max": "0.15",
"sort": "fitness",
"direction": "desc",
"submission": "UNSUBMITTED",
},
"columns": ["name", "fitness"],
}
response = await logged_in.post("/api/v1/research/assets", json={"kind": "view", "content": content})
assert response.status_code == 201, response.text
item = response.json()
assert item["content"]["filters"]["turnover_max"] == 0.15
assert item["content"]["filters"]["sort"] == "fitness"
assert item["content"]["columns"] == ["name", "fitness"]
bad = await logged_in.post(
"/api/v1/research/assets",
json={"kind": "view", "content": {**content, "filters": {"sort": "arbitrary_sql"}}},
)
assert bad.status_code == 422
@pytest.mark.parametrize(
"metrics,verdict,missing",
[
({"sharpe": None, "fitness": 1, "turnover": 0.1}, "review", "sharpe"),
({"sharpe": 2, "fitness": 1, "turnover": 0.1}, "review", "platform_checks"),
(
{"sharpe": 2, "fitness": 1, "turnover": 0.1, "checks": [{"name": "X", "result": "FAIL"}]},
"block",
None,
),
(
{"sharpe": 2, "fitness": 1, "turnover": 0.1, "checks": [{"name": "X", "result": "PASS"}]},
"pass",
None,
),
],
)
async def test_evaluations_keep_missing_and_failures(app, logged_in, metrics, verdict, missing):
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", **{"is": metrics}))
response = await logged_in.post("/api/v1/research/evaluations", json={"alpha_id": "seed"})
assert response.status_code == 201, response.text
report = response.json()["report"]
assert report["verdict"] == verdict
if missing:
assert missing in report["records"][0]["missing"]
async with app.state.sessions() as db:
assert (await db.get(Research, "seed")).state == "inbox"
async def test_sync_and_model_advice_do_not_rewrite_report(app, logged_in):
from app.research.evaluations import Evaluations
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", **{"is": {"sharpe": -1}}))
original = (await logged_in.post("/api/v1/research/evaluations", json={"alpha_id": "seed"})).json()
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", **{"is": {"sharpe": 9}}))
advised = await Evaluations(db).add_advice(
original["id"],
{"summary": "建议通过", "risks": [], "suggestions": []},
{"model": "fixture", "revision": 1},
)
assert advised["id"] != original["id"]
assert advised["report"]["verdict"] == "block"
assert advised["report"]["records"][0]["snapshot"]["is"]["sharpe"] == -1
assert (await logged_in.get(f"/api/v1/research/evaluations/{original['id']}")).json() == original
async def test_lineage_retains_multiple_parents_and_descendants(app, logged_in, research_input):
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed"))
async def create(parents=None, seeds=None):
result = await logged_in.post(
"/api/v1/research/experiments",
json=expansion(
research_input["id"], parent_experiment_ids=parents or [], parent_alpha_ids=seeds or []
),
)
assert result.status_code == 201, result.text
return result.json()["id"]
first, second = await create(seeds=["seed"]), await create(seeds=["seed"])
child = await create([first, second])
grandchild = await create([child])
graph = (await logged_in.get("/api/v1/research/lineage?alpha_id=seed")).json()
assert {r["id"] for r in graph["items"]} == {first, second, child, grandchild}
assert len(graph["edges"]) == 5 and not graph["truncated"]
graph = (await logged_in.get(f"/api/v1/research/lineage?experiment_id={grandchild}")).json()
assert len(graph["items"]) == 4
async def test_evaluate_backtest_keeps_experiment_and_incomplete_evidence(app, logged_in, research_input):
from sqlalchemy import select
from app.models import BacktestResult
from tests.test_backtests import execute, setup, start
experiment = (
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
).json()
preview = (
await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})
).json()
_, lane = await setup(app)
run = await start(logged_in, preview, "stage2-evidence")
await execute(app, lane, run["backtest_run_id"])
async with app.state.sessions.begin() as db:
result = await db.scalar(select(BacktestResult))
result.complete = False
result.snapshot = {
"is": {"sharpe": 2, "fitness": 1, "turnover": 0.1, "checks": [{"name": "X", "result": "PASS"}]}
}
response = await logged_in.post(
"/api/v1/research/evaluations",
json={"experiment_id": experiment["id"], "backtest_run_id": run["backtest_run_id"]},
)
assert response.status_code == 201, response.text
report = response.json()["report"]
assert report["experiment"]["inputs"][0]["id"] == research_input["id"]
incomplete = next(r for r in report["records"] if not r["complete"])
assert incomplete["verdict"] == "review" and "backtest_error" in incomplete["missing"]
assert incomplete["settings"] and incomplete["expression"]
async def test_lineage_crosses_saved_alpha_into_next_research(app, logged_in, research_input):
from tests.test_backtests import execute, setup, start
experiment = (
await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"]))
).json()
preview = (
await logged_in.post(f"/api/v1/research/experiments/{experiment['id']}/preview", json={})
).json()
_, lane = await setup(app)
run = await start(logged_in, preview, "stage2-alpha-lineage")
await execute(app, lane, run["backtest_run_id"])
result = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json()
aid = result["items"][0]["alpha_id"]
child = (
await logged_in.post(
"/api/v1/research/experiments", json=expansion(research_input["id"], parent_alpha_ids=[aid])
)
).json()
for eid in [experiment["id"], child["id"]]:
graph = (await logged_in.get(f"/api/v1/research/lineage?experiment_id={eid}")).json()
assert {r["id"] for r in graph["items"]} == {experiment["id"], child["id"]}
+16 -1
View File
@@ -12,6 +12,7 @@ import {
} from "@douyinfe/semi-ui-19";
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
import { FeaturesPage } from "./research/FeaturesPage";
import { ResearchWorkspace } from "./research/ResearchWorkspace";
import { OperatorsPage } from "./research/OperatorsPage";
import { AppSidebar } from "./components/AppSidebar";
@@ -49,7 +50,7 @@ export default function App() {
page: "templates",
});
useEffect(() => {
if (["operators", "templates", "variants"].includes(page))
if (["operators", "templates", "variants", "features"].includes(page))
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
}, [page]);
const [catalogModal, setCatalogModal] = useState(false);
@@ -370,6 +371,16 @@ export default function App() {
/>
)}
</div>
{visitedResearch.includes("features") && (
<div className="alpha-page-view" hidden={page !== "features"}>
<FeaturesPage
action={aiAction}
active={page === "features"}
onAction={handleAction}
onContext={setResearchContext}
/>
</div>
)}
{visitedResearch.includes("operators") && (
<div className="alpha-page-view" hidden={page !== "operators"}>
<OperatorsPage active={page === "operators"} />
@@ -437,6 +448,10 @@ export default function App() {
onClose={() => setChatOpen(false)}
context={
{
features:
researchContext.page === "features"
? researchContext
: { page: "features" as const },
operators: { page: "operators" as const },
templates:
researchContext.page === "templates"
+3 -1
View File
@@ -18,7 +18,8 @@ export type PageContext = {
| "backtests"
| "operators"
| "templates"
| "variants";
| "variants"
| "features";
research_asset_id?: string;
research_experiment_id?: string;
catalog_scope?: {
@@ -43,6 +44,7 @@ export type AlphaUIAction =
| { type: "open_alpha"; alpha_id: string; nonce: number }
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
export type UIAction =
| { type: "open_feature"; asset_id: string; version?: number; nonce: number }
| { type: "open_template"; asset_id: string; version?: number; nonce: number }
| { type: "open_experiment"; experiment_id: string; nonce: number }
| { type: "open_variant"; alpha_id: string; nonce: number }
+2
View File
@@ -7,6 +7,7 @@ const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
operators: () => "上下文:算子库",
templates: (context) =>
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
features: () => "上下文:特征工程",
variants: () => "上下文:Alpha 变体",
account: () => "上下文:个人信息页",
backtests: () => "上下文:回测研究",
@@ -31,6 +32,7 @@ type Destination = {
};
// Exhaustive action destinations prevent a new action silently falling into Alpha.
const destinations: Record<UIAction["type"], Destination> = {
open_feature: { page: "features", chat: "responsive" },
open_template: { page: "templates", chat: "responsive" },
open_experiment: { page: "templates", chat: "responsive" },
open_variant: { page: "variants", chat: "responsive" },
+12
View File
@@ -32,6 +32,8 @@ import type {
} from "../types";
import { PnlChart } from "./PnlChart";
import { SelfCorrelationPanel } from "./SelfCorrelationPanel";
import { EvaluationPanel } from "../research/EvaluationPanel";
import { LineagePanel } from "../research/LineagePanel";
import { ComparisonPanel } from "../research/ComparisonPanel";
import { AlphaSources } from "../research/AlphaSources";
import type { UIAction } from "../ai/types";
@@ -340,6 +342,16 @@ export function AlphaDetail({
<ComparisonPanel key={detail.id} baseline={detail.id} />
)}
</TabPane>
<TabPane tab="评估报告" itemKey="evaluations">
{tab === "evaluations" && id && (
<EvaluationPanel key={id} alphaId={id} />
)}
</TabPane>
<TabPane tab="变体关系" itemKey="lineage">
{tab === "lineage" && id && (
<LineagePanel key={id} alphaId={id} onAction={onAction} />
)}
</TabPane>
<TabPane tab="研究来源" itemKey="sources">
{tab === "sources" && id && (
<AlphaSources
+1
View File
@@ -24,6 +24,7 @@ const navigation = [
icon: IconGridView,
group: "数据与素材",
},
{ id: "features", label: "特征工程", icon: IconBeaker, group: "研究实验" },
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
+40
View File
@@ -39,6 +39,7 @@ import { AlphaDetail } from "../components/AlphaDetail";
import { AlphaSyncDialog } from "../components/AlphaSyncDialog";
import type { PageContext, AlphaUIAction as UIAction } from "../ai/types";
import type { UIAction as WorkspaceAction } from "../ai/types";
import { SavedViews } from "../research/SavedViews";
import { sourceLabel } from "../research/SourceDetails";
const metricLabels = {
@@ -556,6 +557,45 @@ export function AlphaPage({
{ itemKey: "SUBMITTED", tab: "已提交" },
]}
/>
<SavedViews
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
onRestore={(view) => {
const {
submission: savedSubmission,
sort: savedSort,
direction: savedDirection,
limit,
offset: _offset,
...rest
} = view.filters;
const values = Object.fromEntries(
Object.entries(rest)
.filter(([, v]) => v !== null && v !== undefined)
.map(([k, v]) => [k, String(v)]),
);
setFilters(values);
setDraft(values);
setPage(1);
setSelected([]);
if (
savedSubmission === "SUBMITTED" ||
savedSubmission === "UNSUBMITTED"
)
setSubmission(savedSubmission);
setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit);
const next = [
...new Set([
"name",
...view.columns.filter((c) => c in columnLabels),
]),
];
setVisibleColumns(next);
localStorage.setItem("alpha-columns", JSON.stringify(next));
}}
/>
<form
className="filter-panel"
onSubmit={(e) => {
+254
View File
@@ -0,0 +1,254 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Empty,
InputNumber,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, formatNumber, formatTime, post } from "../api";
import { ResearchSelect } from "./ResearchSelect";
export type Evaluation = {
id: string;
created_at: string;
report: {
verdict: string;
rules: {
version: string;
sharpe_min: number;
fitness_min: number;
turnover_max: number;
};
records: {
alpha_id: string | null;
client_item_id: string | null;
verdict: string;
missing: string[];
failed: string[];
evidence: {
metric: string;
value: number | null;
bound: number;
direction: string;
status: string;
}[];
snapshot: unknown;
}[];
model_advice: {
summary: string;
risks: string[];
suggestions: string[];
} | null;
previous_evaluation_id?: string;
};
};
const labels: Record<string, string> = {
pass: "通过本地筛选",
review: "数据不足 / 待核实",
block: "未通过规则",
};
export function EvaluationPanel({
alphaId,
experimentId,
backtestRunId,
}: {
alphaId?: string;
experimentId?: string;
backtestRunId?: string;
}) {
const [items, setItems] = useState<Evaluation[]>([]);
const [selected, setSelected] = useState<Evaluation | null>(null);
const [rules, setRules] = useState({
version: "research-v1",
sharpe_min: 1,
fitness_min: 0.5,
turnover_max: 0.7,
});
const [busy, setBusy] = useState("");
const [page, setPage] = useState(1);
const [revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController();
api<{ items: Evaluation[] }>(
`/research/evaluations?${alphaId ? `alpha_id=${encodeURIComponent(alphaId)}` : `experiment_id=${encodeURIComponent(experimentId || "")}`}&offset=${(page - 1) * 25}`,
{ signal: controller.signal },
)
.then((r) => setItems(r.items))
.catch((e) => {
if (!controller.signal.aborted) Toast.error(e.message);
});
return () => controller.abort();
}, [alphaId, experimentId, page, revision]);
async function perform(name: string, work: () => Promise<Evaluation>) {
setBusy(name);
try {
setSelected(await work());
setPage(1);
setRevision((n) => n + 1);
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy("");
}
}
return (
<div className="detail-section">
<h3>版本化研究评估</h3>
<p className="muted">
每份报告固定输入快照、规则与指标证据。模型建议单独保存,不改变规则判断或本地研究状态。
</p>
<div className="research-form-grid">
{(
[
["sharpe_min", "Sharpe 下限"],
["fitness_min", "Fitness 下限"],
["turnover_max", "Turnover 上限"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<InputNumber
aria-label={label}
value={rules[key]}
step={0.1}
onChange={(v) => {
if (typeof v === "number") setRules({ ...rules, [key]: v });
}}
/>
</label>
))}
</div>
<div className="inline-actions">
<Button
disabled={Boolean(busy) || (!alphaId && !backtestRunId)}
loading={busy === "evaluate"}
onClick={() =>
void perform("evaluate", () =>
post("/research/evaluations", {
alpha_id: alphaId,
experiment_id: experimentId,
backtest_run_id: backtestRunId,
rules,
}),
)
}
>
保存规则评估
</Button>
<ResearchSelect
label="历史评估报告"
placeholder="查看历史报告"
value={selected?.id}
optionList={items.map((i) => ({
value: i.id,
label: `${formatTime(i.created_at)} · ${labels[i.report.verdict]}`,
}))}
onChange={(v) => setSelected(items.find((i) => i.id === v) || null)}
/>
<Button disabled={page <= 1} onClick={() => setPage((n) => n - 1)}>
上一页
</Button>
<Button
disabled={items.length < 25}
onClick={() => setPage((n) => n + 1)}
>
下一页
</Button>
</div>
{!selected ? (
<Empty description="选择历史报告,或按当前规则保存新评估" />
) : (
<>
<div className="section-toolbar">
<Tag
color={
selected.report.verdict === "pass"
? "green"
: selected.report.verdict === "block"
? "red"
: "orange"
}
>
{labels[selected.report.verdict]}
</Tag>
<span className="muted">
{formatTime(selected.created_at)} ·{" "}
{selected.report.rules.version}
</span>
</div>
<p>
规则:Sharpe ≥ {selected.report.rules.sharpe_min},Fitness ≥{" "}
{selected.report.rules.fitness_min},Turnover ≤{" "}
{selected.report.rules.turnover_max}
</p>
{selected.report.records.map((r, index) => (
<div className="research-card" key={index}>
<h4>
{r.alpha_id || r.client_item_id || "未返回 Alpha"} ·{" "}
{labels[r.verdict]}
</h4>
{r.evidence.map((e) => (
<div className="check-row" key={e.metric}>
<span>
{e.metric}: {formatNumber(e.value)}
</span>
<span>
{e.direction === "min" ? "≥" : "≤"} {e.bound}
</span>
<Tag>
{e.status === "unknown"
? "缺失"
: e.status === "pass"
? "通过"
: "失败"}
</Tag>
</div>
))}
{r.missing.length > 0 && (
<p>缺失或未决:{r.missing.join("、")}</p>
)}
{r.failed.length > 0 && <p>未通过:{r.failed.join("、")}</p>}
<details>
<summary>当时的输入快照</summary>
<pre className="code-block">
{JSON.stringify(r.snapshot, null, 2)}
</pre>
</details>
</div>
))}
<Banner
type="info"
description="这是本地研究筛选,不代表官方提交资格。已有平台检查缺失时保留为待核实。"
/>
{selected.report.model_advice && (
<div className="research-card">
<h4>模型建议</h4>
<p>{selected.report.model_advice.summary}</p>
<p>
风险:
{selected.report.model_advice.risks.join(";") || "未提供"}
</p>
<p>
建议:
{selected.report.model_advice.suggestions.join(";") ||
"未提供"}
</p>
</div>
)}
<Button
disabled={Boolean(busy)}
loading={busy === "advice"}
onClick={() =>
void perform("advice", () =>
post(`/research/evaluations/${selected.id}/advice`, {}),
)
}
>
生成建议并保存为新报告
</Button>
</>
)}
</div>
);
}
+31
View File
@@ -3,6 +3,9 @@ import { Banner, Button, Checkbox, Tag, Toast } from "@douyinfe/semi-ui-19";
import { post, formatTime } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
import { EvaluationPanel } from "./EvaluationPanel";
import { LineagePanel } from "./LineagePanel";
import { ResearchSelect } from "./ResearchSelect";
import { validationLabel } from "./workspaceTypes";
export function ExperimentView({
@@ -13,6 +16,9 @@ export function ExperimentView({
onAction: (action: UIAction) => void;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [assessment, setAssessment] = useState(false);
const [relations, setRelations] = useState(false);
const [runId, setRunId] = useState(experiment.backtest_run_ids[0]);
const [busy, setBusy] = useState(false);
const valid = experiment.candidates.filter(
(c) => c.validation.status === "valid",
@@ -208,6 +214,31 @@ export function ExperimentView({
</Button>
))}
</div>
<div className="inline-actions">
<Button onClick={() => setAssessment((v) => !v)}>评估研究结果</Button>
<Button onClick={() => setRelations((v) => !v)}>查看变体关系</Button>
</div>
{assessment && (
<>
<ResearchSelect
label="待评估的回测运行"
value={runId}
optionList={experiment.backtest_run_ids.map((id) => ({
value: id,
label: id,
}))}
onChange={(v) => setRunId(String(v))}
/>
<EvaluationPanel
key={experiment.id}
experimentId={experiment.id}
backtestRunId={runId}
/>
</>
)}
{relations && (
<LineagePanel experimentId={experiment.id} onAction={onAction} />
)}
</section>
);
}
+386
View File
@@ -0,0 +1,386 @@
import { useEffect, useRef, useState } from "react";
import {
Banner,
Button,
Input,
Pagination,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { TemplateEditor } from "./TemplateEditor";
import { ResearchSelect } from "./ResearchSelect";
import { blankTemplate } from "./workspaceTypes";
import type { Asset, InputSnapshot, Template } from "./workspaceTypes";
import "./workspace.css";
type Feature = {
name: string;
hypothesis: string;
input_ids: string[];
steps: { name: string; rationale: string; expression: string }[];
template: Template | null;
};
type FeatureAsset = Omit<Asset, "content"> & { content: Feature };
const blank = (): Feature => ({
name: "新特征方案",
hypothesis: "",
input_ids: [],
steps: [],
template: blankTemplate(),
});
export function FeaturesPage({
active,
onAction,
onContext,
action,
}: {
active: boolean;
action?: UIAction | null;
onAction: (action: UIAction) => void;
onContext: (context: PageContext) => void;
}) {
const [draft, setDraft] = useState<Feature>(blank);
const [asset, setAsset] = useState<FeatureAsset | null>(null);
const [items, setItems] = useState<FeatureAsset[]>([]);
const [inputs, setInputs] = useState<InputSnapshot[]>([]);
const [versions, setVersions] = useState<{ version: number }[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [q, setQ] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
useEffect(() => {
if (!active) return;
const controller = new AbortController();
Promise.all([
api<{ items: FeatureAsset[]; total: number }>(
`/research/assets?kind=feature&q=${encodeURIComponent(q)}&offset=${(page - 1) * 25}`,
{ signal: controller.signal },
),
api<{ items: InputSnapshot[] }>("/research/inputs", {
signal: controller.signal,
}),
])
.then(([list, fixed]) => {
setItems(list.items);
setTotal(list.total);
setInputs(fixed.items);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}, [active, page, q, revision]);
useEffect(() => {
if (active) onContext({ page: "features", research_asset_id: asset?.id });
}, [active, asset?.id, onContext]);
const handled = useRef(0);
useEffect(() => {
if (
!active ||
action?.type !== "open_feature" ||
handled.current === action.nonce
)
return;
handled.current = action.nonce;
void run("load", async () =>
load(
await api(
`/research/assets/${action.asset_id}${action.version ? `?version=${action.version}` : ""}`,
),
),
);
}, [active, action]);
async function load(item: FeatureAsset) {
setAsset(item);
setDraft(item.content);
setVersions(await api(`/research/assets/${item.id}/versions`));
}
async function run(name: string, work: () => Promise<void>) {
setBusy(name);
setError("");
try {
await work();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy("");
}
}
async function save() {
const saved = await api<FeatureAsset>(
`/research/assets${asset ? `/${asset.id}` : ""}`,
{
method: asset ? "PUT" : "POST",
body: JSON.stringify({
kind: "feature",
version: asset?.version,
content: draft,
}),
},
);
await load(saved);
setRevision((n) => n + 1);
return saved;
}
return (
<section className="research-workspace">
<div className="section-toolbar">
<div>
<h2>特征工程</h2>
<p className="muted">
围绕固定字段记录处理步骤与经济假设,再进入模板、候选和回测闭环。
</p>
</div>
<Button
onClick={() => {
setAsset(null);
setDraft(blank());
setVersions([]);
}}
>
新建特征方案
</Button>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<Input
aria-label="搜索特征方案"
placeholder="搜索方案"
value={q}
onChange={(v) => {
setQ(v);
setPage(1);
}}
/>
{items.map((item) => (
<Button
key={item.id}
block
theme={asset?.id === item.id ? "light" : "borderless"}
onClick={() => void run("load", () => load(item))}
>
{item.name} · v{item.version}
</Button>
))}
<Pagination
size="small"
currentPage={page}
pageSize={25}
total={total}
onPageChange={setPage}
/>
</aside>
<div className="research-main">
<div className="inline-actions">
{asset && (
<>
<span>版本</span>
<ResearchSelect
label="特征方案版本"
value={asset.version}
optionList={versions.map((v) => ({
value: v.version,
label: `v${v.version}`,
}))}
onChange={(v) =>
void run("load", async () => {
await load(
await api(`/research/assets/${asset.id}?version=${v}`),
);
})
}
/>
</>
)}
</div>
<label>
方案名称
<Input
aria-label="特征方案名称"
value={draft.name}
onChange={(name) => setDraft({ ...draft, name })}
/>
</label>
<label>
经济假设
<TextArea
aria-label="特征经济假设"
value={draft.hypothesis}
onChange={(hypothesis) => setDraft({ ...draft, hypothesis })}
/>
</label>
<label>
固定输入
<ResearchSelect
label="特征固定输入"
multiple
filter
value={draft.input_ids}
optionList={inputs.map((i) => ({
value: i.id,
label: `${i.dataset_id} · ${i.scope.region}/${i.scope.universe}/D${i.scope.delay} · ${i.field_ids.length} 字段 · ${i.id.slice(0, 8)}`,
}))}
onChange={(v) => setDraft({ ...draft, input_ids: v as string[] })}
/>
</label>
{inputs
.filter((i) => draft.input_ids.includes(i.id))
.map((i) => (
<details key={i.id}>
<summary>{i.dataset_id} 的固定字段</summary>
<p className="muted">{i.field_ids.join("、")}</p>
</details>
))}
<div className="section-toolbar">
<h3>处理与迭代步骤</h3>
<Button
onClick={() =>
setDraft({
...draft,
steps: [
...draft.steps,
{ name: "", rationale: "", expression: "" },
],
})
}
>
添加处理步骤
</Button>
</div>
{draft.steps.map((step, index) => (
<div className="research-card" key={index}>
<label>
步骤 {index + 1}
<Input
aria-label={`步骤 ${index + 1} 名称`}
value={step.name}
onChange={(name) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, name } : s,
),
})
}
/>
</label>
<label>
理由
<TextArea
aria-label={`步骤 ${index + 1} 理由`}
value={step.rationale}
onChange={(rationale) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, rationale } : s,
),
})
}
/>
</label>
<label>
候选表达式
<TextArea
aria-label={`步骤 ${index + 1} 表达式`}
value={step.expression}
onChange={(expression) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, expression } : s,
),
})
}
/>
</label>
<Button
type="danger"
theme="borderless"
onClick={() =>
setDraft({
...draft,
steps: draft.steps.filter((_, i) => i !== index),
})
}
>
删除此步骤
</Button>
</div>
))}
<h3>输出模板</h3>
<TemplateEditor
value={draft.template || blankTemplate()}
inputs={inputs.filter((i) => draft.input_ids.includes(i.id))}
onChange={(template) => setDraft({ ...draft, template })}
/>
<div className="inline-actions">
<Button
theme="solid"
loading={busy === "save"}
disabled={Boolean(busy)}
onClick={() =>
void run("save", async () => {
await save();
Toast.success("特征方案版本已保存");
})
}
>
保存特征方案
</Button>
<Button
disabled={Boolean(busy)}
loading={busy === "convert"}
onClick={() =>
void run("convert", async () => {
const fixed = await save();
const template = await post<Asset>(
`/research/features/${fixed.id}/template`,
{ version: fixed.version },
);
onAction({
type: "open_template",
asset_id: template.id,
version: template.version,
nonce: Date.now(),
});
})
}
>
保存并转为模板
</Button>
<Button
disabled={
Boolean(busy) || !draft.input_ids.length || !draft.hypothesis
}
loading={busy === "generate"}
onClick={() =>
void run("generate", async () => {
await load(
await post<FeatureAsset>("/research/generate", {
name: draft.name,
hypothesis: draft.hypothesis,
input_ids: draft.input_ids,
method: "feature",
}),
);
setRevision((n) => n + 1);
})
}
>
AI 生成新方案
</Button>
</div>
<p className="research-hint">
保存步骤不会计算原始时间序列。转换后的模板保留此方案的完整版本,回测前仍需展开校验并确认候选。
</p>
</div>
</div>
</section>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { Banner, Button, Empty, Toast } from "@douyinfe/semi-ui-19";
import { api } from "../api";
import type { UIAction } from "../ai/types";
import type { Experiment } from "./workspaceTypes";
type Graph = {
items: Experiment[];
edges: { child_id: string; parent_kind: string; parent_id: string }[];
truncated: boolean;
unresolved_experiment_ids: string[];
};
export function LineagePanel({
alphaId,
experimentId,
onAction,
}: {
alphaId?: string;
experimentId?: string;
onAction: (a: UIAction) => void;
}) {
const [graph, setGraph] = useState<Graph | null>(null);
const [focus, setFocus] = useState(experimentId);
useEffect(() => {
const c = new AbortController();
api<Graph>(
`/research/lineage?${focus ? `experiment_id=${encodeURIComponent(focus)}` : `alpha_id=${encodeURIComponent(alphaId || "")}`}`,
{ signal: c.signal },
)
.then(setGraph)
.catch((e) => {
if (!c.signal.aborted) Toast.error(e.message);
});
return () => c.abort();
}, [alphaId, focus]);
return (
<div className="detail-section">
<h3>变体关系</h3>
<p className="muted">
每个实验保留所有父来源;每次回测来源另见「研究来源」。选择实验可继续查看输入、设置差异、候选及回测。
</p>
{graph?.truncated && (
<Banner
type="info"
description="关系图按 100 个实验、8 层展示;可从边界实验继续展开。"
/>
)}
{graph?.items.length === 0 && <Empty description="尚无本地变体关系" />}
{graph?.items.map((e) => (
<div className="research-card" key={e.id}>
<h4>{e.name}</h4>
<p>{e.hypothesis}</p>
<p>
方法:{e.evidence.method || e.kind} · 模板{" "}
{e.evidence.template?.name || "—"}{" "}
{e.evidence.template?.version
? `v${e.evidence.template.version}`
: ""}
</p>
<div className="inline-actions">
{e.parents.map((p) => (
<Button
key={`${p.kind}:${p.id}`}
size="small"
onClick={() =>
p.kind === "alpha"
? onAction({
type: "open_alpha",
alpha_id: p.id,
nonce: Date.now(),
})
: setFocus(p.id)
}
>
父{p.kind === "alpha" ? " Alpha" : "实验"} {p.id.slice(0, 12)}
</Button>
))}
</div>
<p className="muted">
{e.candidates.length} 个候选 · {e.backtest_run_ids.length} 次回测
</p>
<div className="inline-actions">
<Button
onClick={() =>
onAction({
type: "open_experiment",
experiment_id: e.id,
nonce: Date.now(),
})
}
>
查看候选与来源
</Button>
<Button theme="borderless" onClick={() => setFocus(e.id)}>
以此实验展开关系
</Button>
</div>
</div>
))}
{graph?.unresolved_experiment_ids.map((id) => (
<Button key={id} onClick={() => setFocus(id)}>
继续展开 {id}
</Button>
))}
</div>
);
}
+5 -2
View File
@@ -8,13 +8,16 @@ export function ResearchToolCard({ call, onAction }: ToolCardProps) {
{typeof result.name === "string" ? result.name : "研究资料"}
{typeof result.version === "number" ? ` · v${result.version}` : ""}
</p>
{typeof result.id === "string" && (
{typeof result.id === "string" && !result.report && (
<Button
onClick={() =>
onAction(
result.content
? {
type: "open_template",
type:
result.kind === "feature"
? "open_feature"
: "open_template",
asset_id: result.id as string,
version: result.version as number,
nonce: Date.now(),
@@ -132,6 +132,24 @@ export function ResearchWorkspace({
);
setAsset(next);
setTemplate(next.content);
const feature = next.provenance?.feature;
if (feature) {
setInputIds(feature.content.input_ids);
setHypothesis(feature.content.hypothesis);
const fixed = feature.provenance?.inputs || [];
setInputs((old) => [
...old,
...fixed.filter((i) => !old.some((o) => o.id === i.id)),
]);
const first = fixed[0];
if (first)
setSettings((old) => ({
...old,
region: first.scope.region,
universe: first.scope.universe,
delay: first.scope.delay,
}));
}
});
if (action.type === "open_variant") {
setParent(action.alpha_id);
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Button, Input, Toast } from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import { ResearchSelect } from "./ResearchSelect";
export type ViewContent = {
name: string;
filters: Record<string, unknown>;
columns: string[];
};
type View = { id: string; version: number; content: ViewContent };
export function SavedViews({
filters,
columns,
onRestore,
}: {
filters: Record<string, unknown>;
columns: string[];
onRestore: (view: ViewContent) => void;
}) {
const [items, setItems] = useState<View[]>([]);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string>();
const [busy, setBusy] = useState(false);
const [q, setQ] = useState("");
const [version, setVersion] = useState(0);
useEffect(() => {
const controller = new AbortController();
api<{ items: View[] }>(
`/research/assets?kind=view&q=${encodeURIComponent(q)}&limit=100`,
{ signal: controller.signal },
)
.then((r) => setItems(r.items))
.catch((e) => {
if (!controller.signal.aborted) Toast.error(e.message);
});
return () => controller.abort();
}, [q, version]);
async function save(replace: boolean) {
setBusy(true);
try {
const old = items.find((i) => i.id === selected);
if (replace && !old) return;
const content = {
name: name.trim() || old?.content.name || "研究队列",
filters: Object.fromEntries(
Object.entries(filters).filter(
([, v]) => v !== "" && v !== null && v !== undefined,
),
),
columns,
};
const value = replace
? await api<View>(`/research/assets/${old!.id}`, {
method: "PUT",
body: JSON.stringify({
kind: "view",
version: old!.version,
content,
}),
})
: await post<View>("/research/assets", { kind: "view", content });
setSelected(value.id);
setName(value.content.name);
setVersion((n) => n + 1);
Toast.success("筛选视图已保存");
} catch (e) {
Toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="research-saved-views inline-actions">
<ResearchSelect
label="保存的 Alpha 视图"
placeholder="搜索并恢复研究队列"
filter
remote
onSearch={setQ}
value={selected}
optionList={items.map((i) => ({
value: i.id,
label: `${i.content.name} · v${i.version}`,
}))}
onChange={(v) => {
const item = items.find((i) => i.id === v);
if (item) {
setSelected(item.id);
setName(item.content.name);
onRestore(item.content);
}
}}
/>
<Input
aria-label="视图名称"
placeholder="视图名称"
value={name}
onChange={setName}
style={{ width: 160 }}
/>
<Button size="small" disabled={busy} onClick={() => void save(false)}>
保存为新视图
</Button>
<Button
size="small"
disabled={busy || !selected}
onClick={() => void save(true)}
>
更新当前视图
</Button>
</div>
);
}
+9
View File
@@ -226,3 +226,12 @@
clip-path: inset(50%);
white-space: nowrap;
}
.research-saved-views {
padding: 8px 16px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.research-saved-views .semi-select {
min-width: 210px;
}
+6
View File
@@ -25,6 +25,12 @@ export type Asset = {
kind: string;
version: number;
content: Template;
provenance?: {
feature?: {
content: { input_ids: string[]; hypothesis: string };
provenance?: { inputs?: InputSnapshot[] };
};
};
created_at?: string;
};
export type InputSnapshot = {
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
};
test("feature conversion, saved views and immutable evaluations", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/#features");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("heading", { name: "特征工程", exact: true }),
).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");
for (const dataset_id of [null, "TEST_FIN"]) {
const result = await page.request.post("/api/v1/catalog/sync-jobs", {
headers,
data: { scope, dataset_id },
});
const job = await result.json();
await expect
.poll(
async () =>
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
.status,
)
.toBe("completed");
}
const params = new URLSearchParams(
Object.entries(scope).map(([k, v]) => [k, String(v)]),
);
const fields = await (
await page.request.get(`/api/v1/catalog/datasets/TEST_FIN/fields?${params}`)
).json();
const fixed = await (
await page.request.post("/api/v1/catalog/inputs", {
headers,
data: {
scope,
dataset_id: "TEST_FIN",
collection_version: fields.collection_version,
selection: "all",
},
})
).json();
expect(fixed.id).toBeTruthy();
await page.reload();
await page.getByLabel("特征方案名称", { exact: true }).fill("浏览器特征验收");
await page
.getByLabel("特征经济假设", { exact: true })
.fill("排名可降低异常值影响");
await page.getByRole("combobox", { name: "特征固定输入" }).click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await page.getByRole("heading", { name: "特征工程", exact: true }).click();
await page.getByRole("button", { name: "添加处理步骤" }).click();
await page.getByLabel("步骤 1 名称").fill("横截面排名");
await page.getByLabel("步骤 1 理由").fill("减少尺度影响");
await page.getByLabel("步骤 1 表达式").fill("rank(TEST_FIN_001)");
await page
.getByRole("textbox", { name: "模板名称", exact: true })
.fill("特征转换模板");
await page.getByLabel("field 候选取值").fill("TEST_FIN_001");
await page.getByRole("button", { name: "保存特征方案", exact: true }).click();
await expect(
page.getByText("特征方案版本已保存", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "保存并转为模板" }).click();
await expect(page).toHaveURL(/#templates$/);
await expect(
page.getByRole("textbox", { name: "模板名称", exact: true }),
).toHaveValue("特征转换模板");
const templates = await (
await page.request.get(
"/api/v1/research/assets?kind=template&q=特征转换模板",
)
).json();
expect(templates.items[0].provenance.feature.content.input_ids).toEqual([
fixed.id,
]);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByLabel("视图名称", { exact: true }).fill("浏览器常用队列");
await page.getByRole("button", { name: "保存为新视图" }).click();
await expect(page.getByText("筛选视图已保存", { exact: true })).toBeVisible();
const views = await (
await page.request.get("/api/v1/research/assets?kind=view&q=浏览器常用队列")
).json();
expect(views.items[0].content.filters.submission).toBe("UNSUBMITTED");
expect(views.items[0].content.columns).toContain("name");
await page
.getByRole("button", { name: "导入 Alpha ID", exact: true })
.click();
await page.getByLabel("Alpha IDs", { exact: true }).fill("TEST0001");
const importing = page.waitForResponse(
(r) =>
r.url().endsWith("/api/v1/sync-jobs") && r.request().method() === "POST",
);
await page
.getByRole("dialog")
.getByRole("button", { name: "confirm", exact: true })
.click();
await expect
.poll(async () =>
(await page.request.get("/api/v1/alphas/TEST0001")).status(),
)
.toBe(200);
const importJob = await (await importing).json();
await expect
.poll(
async () =>
(
await (
await page.request.get(`/api/v1/sync-jobs/${importJob.id}`)
).json()
).status,
)
.toBe("completed");
await page.reload();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
await page.getByRole("button", { name: "查询", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(1);
const alpha = await (
await page.request.get("/api/v1/alphas/TEST0001")
).json();
await page
.getByRole("button", { name: alpha.name || alpha.id, exact: true })
.click();
await page.getByRole("tab", { name: "评估报告", exact: true }).click();
await page.getByRole("button", { name: "保存规则评估" }).click();
await expect(
page.getByText("规则:Sharpe ≥", { exact: false }),
).toBeVisible();
const reports = await (
await page.request.get("/api/v1/research/evaluations?alpha_id=TEST0001")
).json();
expect(reports.items).toHaveLength(1);
await page.screenshot({
path: "/tmp/wq-stage-two-evaluation.png",
fullPage: true,
});
expect(errors).toEqual([]);
});
+2
View File
@@ -59,6 +59,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
await page.getByLabel("同步开始日期").fill("2025-01-01");
await page.getByLabel("同步结束日期").fill("2025-01-01");
await page.getByRole("button", { name: "开始同步", exact: true }).click();
await expect(page.locator(".job-panel")).toBeVisible();
await expect
.poll(
async () =>
@@ -72,6 +73,7 @@ test("account → scoped sync → research → resync → PnL → filtered expor
await page.keyboard.press("Escape");
await page.getByRole("tab", { name: "已提交", exact: true }).click();
await page.getByRole("button", { name: "全量同步已提交" }).click();
await expect(page.locator(".job-panel")).toBeVisible();
await expect
.poll(
async () =>