feat: compose native research workflows in QuantFlow

This commit is contained in:
yuxuanhui
2026-09-08 22:42:29 +08:00
parent 7860434b04
commit a6e36e50ec
22 changed files with 2274 additions and 85 deletions
@@ -0,0 +1,46 @@
# 第四阶段验收:QuantFlow 原生研究画布
日期:2026-09-08。范围:流程画布、固定流程版本、原生节点及共用研究执行。所有平台、账户和模型响应均为隔离合成数据,没有调用真实模型或 WorldQuant。
## 交付
- 研究编排分组增加 QuantFlow,保留原侧栏、最近访问和 AI 助手。流程库、画布、节点配置、连线、版本与运行记录可直接使用。
- 画布支持拖动及方向键移动节点、缩放、适应宽度,配置素材版本、研究说明、变体方法、筛选结论和迭代轮数。草稿保留本地基准版本,冲突更新返回 409;历史版本可查看或另存。
- 原生节点包括固定输入、特征方案、模板生成/增强、模板展开、两类变体、回测、规则评估、筛选、条件、汇总、有界迭代。固定六阶段研究可复制为内置配方。
- 保存及启动都验证节点唯一性、连接端点、输入输出类型、普通连接无环、可达性、配置和迭代约束。首版一个流程最多 50 节点、100 连线;一个终点迭代节点重复整个流程,最多 100 轮,不能超过本次授权轮数。
- 运行固定工作流版本、节点引用的模板/特征版本、输入、种子、规则和预算。启动不含模型节点的流程无需配置模型;所有预算仍须为有限正整数。
- 模板、特征及结构变体复用统一表达式模块;设置变体冻结种子表达式及原设置,按独立目标输入计算合法设置,回测前再次核对授权范围。候选上限与采样种子保留。
- 条件按规则报告的 pass/review/block 选择出边,未命中分支和空筛选不会执行后续模拟。汇总保存产物引用,避免递归复制整个上游图。
- 完全复用研究运行、步骤、预算、预览、回测调度和评估实现;重复事件不增加模拟,刷新后恢复运行和节点产物显示。
- 模型返回后先保存产物;特征转换和结构变体展开在下一次活跃步骤进行。暂停、停止、账户改变期间只收集已发结果,不继续转换或展开;恢复不重复已完成模型调用。
- AI 可读取已有流程版本、研究运行及预算;普通 Chatbox 不能借来源字段获得自动执行授权。
## 验收结果
| 验收项 | 结果 |
|---|---|
| 非法节点图、连线、循环和配置 | 通过;重复边、断连、类型不符、普通循环、布尔迭代上限、未知脚本配置、非法筛选和素材版本返回 422 |
| 保存后修改模板和流程 | 通过;旧运行继续使用 v1,模板更新为无效表达式不影响已启动版本 |
| 三路条件与汇总 | 通过;仅一个匹配分支执行,其余两个跳过;汇总保存多个上游引用 |
| 固定特征 → 模板 → 展开 → 回测 → 筛选 → 迭代 | 通过;两轮共 4 条模拟、0 次模型调用,特征原版本可追溯 |
| 模型特征与结构变体 | 通过;各使用一次模型调用,保存特征/模板、候选及种子关系 |
| 市场与设置变体 | 通过;启动后种子被改动仍使用原表达式,目标输入和股票池严格对应,没有调用模型 |
| 空筛选、重复完成事件 | 通过;不发后续模拟,不重复回测 |
| 模型期间暂停、停止、账户变化 | 通过;模型产物持久化但没有新候选/回测,暂停后恢复复用产物,模型调用数保持 1 |
| 后端全量回归 | 最终 220 passed,日志 `/tmp/wq-stage4-full-backend-final.log` |
| 静态检查和前端构建 | Ruff、TypeScript、构建及 diff whitespace 检查通过;保留依赖 lottie-web 原有 eval 构建警告 |
| 浏览器画布闭环 | 通过;新建 8 节点、连线、键盘移动、保存 v1/v2、预算确认、固定 v2 启动、分支和刷新恢复,模拟 2 条、模型调用 0 次 |
| 浏览器回归 | 8 passed,覆盖 QuantFlow、固定流水线、特征/成果、侧栏、4 项 AI 行为;日志 `/tmp/wq-stage4-browser-regression.log` |
| PostgreSQL 17 与备份恢复 | 通过;从第三阶段备份恢复,Alembic head 0008 无新增迁移差异,两轮原生图执行与重放正常;pg_dump/pg_restore 后完整运行/步骤快照摘要及旧研究备注一致。脚本 `backend/tests/research_quantflow_postgres.py`,日志 `/tmp/wq-stage4-postgres.log` |
画布连线路由和适应宽度调整后,单独复验 1 passed,日志 `/tmp/wq-stage4-canvas-final.log`。
最终截图:`/tmp/wq-stage-four-canvas.png`、`/tmp/wq-stage-four-run.png`。主代理已检查画布、运行状态、来源入口和中文界面。
独立只读核验已完成。其指出暂停后的模型回调仍执行变体展开,已拆成持久的模型产物与后续转换步骤,增加暂停、停止及账户变化回归。全量测试过程中发现测试辅助函数漏导入和 AI 工具重复注册,均已修正,以上列出的是最终通过结果。
## 边界与待联调
首版迭代重复整个流程,普通节点各接受一个上游,汇总允许多个上游;不提供任意脚本、CLI、外部事件自动启动或正式 Alpha 提交。流程编辑与启动分开,扩大预算和范围须重新确认新运行。
真实 WorldQuant 认证、元数据协议及模拟联调仍待当前对话确认授权。自动审批此前拒绝了读取 account.json 后向官方平台联调的提权命令,理由是未将任务目标记录中的授权认可为当前用户消息授权;没有绕过审批。本轮合成测试不能证明真实字段可用性和平台合法设置协议已联调通过。
@@ -7,8 +7,8 @@ Status: ready-for-agent
- [x] 一:算子、模板、表达式模块、两类变体与回测来源闭环。 - [x] 一:算子、模板、表达式模块、两类变体与回测来源闭环。
- [x] 二:特征方案、保存视图、关系、比较和评估。 - [x] 二:特征方案、保存视图、关系、比较和评估。
- [x] 三:固定自动研究、预算、持久步骤、恢复。 - [x] 三:固定自动研究、预算、持久步骤、恢复。
- [ ] 四:原生 QuantFlow 画布与共用执行。 - [x] 四:原生 QuantFlow 画布与共用执行。
- [ ] 验证:后端、前端、浏览器、PostgreSQL 迁移与恢复。 - [x] 验证:后端、前端、浏览器、PostgreSQL 迁移与恢复。
## Comments ## Comments
@@ -19,3 +19,5 @@ Status: ready-for-agent
第二阶段已通过后端、前端、浏览器和 PostgreSQL 迁移/恢复验证;报告见 `../acceptance/stage-2.md`。独立 Alpha 列表布局改动继续留在工作区,本次仅提交保存视图接入。 第二阶段已通过后端、前端、浏览器和 PostgreSQL 迁移/恢复验证;报告见 `../acceptance/stage-2.md`。独立 Alpha 列表布局改动继续留在工作区,本次仅提交保存视图接入。
第三阶段固定研究及有限授权已验收,报告见 `../acceptance/stage-3.md`。 第三阶段固定研究及有限授权已验收,报告见 `../acceptance/stage-3.md`。
第四阶段原生 QuantFlow 已完成本地后端、前端、浏览器及 PostgreSQL 验收,报告见 `../acceptance/stage-4.md`。四阶段实现与模拟验证完成;真实 WorldQuant 联调未执行,限制详见各阶段报告。
+1
View File
@@ -46,6 +46,7 @@ class PageContext(Contract):
"features", "features",
"variants", "variants",
"pipeline", "pipeline",
"quantflow",
] = "alphas" ] = "alphas"
research_run_id: str | None = Field(default=None, max_length=36) research_run_id: str | None = Field(default=None, max_length=36)
research_asset_id: str | None = Field(default=None, max_length=36) research_asset_id: str | None = Field(default=None, max_length=36)
+6 -3
View File
@@ -310,8 +310,10 @@ class Experiments:
preserve_source=True, preserve_source=True,
) )
async def setting_variants(self, body): async def setting_variants(self, body, *, parent_snapshot=None, extra_evidence=None, kind="variant"):
parents = await self.parents([body.alpha_id], []) parents = (
[parent_snapshot] if parent_snapshot is not None else await self.parents([body.alpha_id], [])
)
original = parents[0] original = parents[0]
base = SimulationSettings.model_validate(original["settings"]) base = SimulationSettings.model_validate(original["settings"])
expression = original["expression"] expression = original["expression"]
@@ -362,12 +364,13 @@ class Experiments:
) )
return await self.save( return await self.save(
f"{body.alpha_id} · 设置变体", f"{body.alpha_id} · 设置变体",
"variant", kind,
body.hypothesis, body.hypothesis,
snapshots, snapshots,
parents, parents,
candidates, candidates,
{ {
**(extra_evidence or {}),
"method": "settings", "method": "settings",
"rejected": rejected, "rejected": rejected,
"operators_snapshot": operators_snapshot, "operators_snapshot": operators_snapshot,
+45 -9
View File
@@ -22,6 +22,7 @@ from .workspace_contracts import (
ImportCommit, ImportCommit,
ImportPreview, ImportPreview,
SettingVariants, SettingVariants,
WorkflowSpec,
) )
router = APIRouter(prefix="/api/v1/research", tags=["research"], dependencies=[Depends(require_auth)]) router = APIRouter(prefix="/api/v1/research", tags=["research"], dependencies=[Depends(require_auth)])
@@ -41,7 +42,7 @@ async def assets(
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),
): ):
if kind not in ("template", "feature", "view"): if kind not in ("template", "feature", "view", "workflow"):
raise HTTPException(422, "当前素材类型尚未开放") raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Assets(db).list(kind, q, limit, offset) return await Assets(db).list(kind, q, limit, offset)
@@ -49,7 +50,7 @@ async def assets(
@router.post("/assets", status_code=201) @router.post("/assets", status_code=201)
async def save_asset(body: AssetWrite, request: Request): async def save_asset(body: AssetWrite, request: Request):
if body.kind not in ("template", "feature", "view"): if body.kind not in ("template", "feature", "view", "workflow"):
raise HTTPException(422, "当前素材类型尚未开放") raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
return await Assets(db).save(body) return await Assets(db).save(body)
@@ -63,7 +64,7 @@ async def asset(asset_id: str, request: Request, version: int | None = Query(Non
@router.put("/assets/{asset_id}") @router.put("/assets/{asset_id}")
async def update_asset(asset_id: str, body: AssetWrite, request: Request): async def update_asset(asset_id: str, body: AssetWrite, request: Request):
if body.kind not in ("template", "feature", "view"): if body.kind not in ("template", "feature", "view", "workflow"):
raise HTTPException(422, "当前素材类型尚未开放") raise HTTPException(422, "当前素材类型尚未开放")
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
return await Assets(db).save(body, asset_id) return await Assets(db).save(body, asset_id)
@@ -240,21 +241,42 @@ async def fixed_recipe():
async def start_flow(body: FlowStart, request: Request): async def start_flow(body: FlowStart, request: Request):
from .workflows import Workflows from .workflows import Workflows
if body.workflow_id:
raise HTTPException(422, "自定义流程将在 QuantFlow 阶段开放")
async with request.app.state.sessions.begin() as db: async with request.app.state.sessions.begin() as db:
config = await request.app.state.ai.config(db) from .workflows import fixed_workflow
result = await Workflows(db).start(body, config.revision) from .workspace_contracts import WorkflowSpec
graph = (
WorkflowSpec.model_validate(
(await Assets(db).get(body.workflow_id, body.workflow_version, "workflow"))["content"]
)
if body.workflow_id
else fixed_workflow(body.budget.max_rounds)
)
needs_model = any(
n.type == "generate"
or (n.type == "feature" and not n.config.get("asset_id"))
or (n.type == "variant" and n.config.get("method", "structure") == "structure")
for n in graph.nodes
)
config = await request.app.state.ai.config(db) if needs_model else None
result = await Workflows(db).start(body, config.revision if config else None)
request.app.state.research.wake.set() request.app.state.research.wake.set()
return result return result
@router.get("/flows/runs") @router.get("/flows/runs")
async def flow_runs(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)): async def flow_runs(
request: Request,
limit: int = Query(25, ge=1, le=100),
offset: int = Query(0, ge=0),
kind: str | None = None,
):
from .workflows import Workflows from .workflows import Workflows
async with request.app.state.sessions() as db: async with request.app.state.sessions() as db:
return await Workflows(db).list(limit, offset) if kind not in (None, "pipeline", "quantflow"):
raise HTTPException(422, "未知研究运行类型")
return await Workflows(db).list(limit, offset, kind)
@router.get("/flows/runs/{run_id}") @router.get("/flows/runs/{run_id}")
@@ -276,3 +298,17 @@ async def control_flow(run_id: str, body: FlowControl, request: Request):
request.app.state.research.wake.set() request.app.state.research.wake.set()
request.app.state.runner.backtests.wake.set() request.app.state.runner.backtests.wake.set()
return result return result
@router.get("/flows/nodes")
async def flow_nodes():
from .workflows import NODE_TYPES
return {"items": [{"type": key, **value} for key, value in NODE_TYPES.items()]}
@router.post("/flows/validate")
async def validate_flow(body: WorkflowSpec):
from .workflows import validate_graph
return {"valid": True, "order": validate_graph(body)}
+211 -53
View File
@@ -6,21 +6,32 @@ Backtest source fields are provenance, never a grant to execute automatically.
import asyncio import asyncio
import logging import logging
import random
from collections import defaultdict from collections import defaultdict
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from ..backtests.contracts import ControlInput, StartInput from ..backtests.contracts import ControlInput, SimulationSettings, StartInput
from ..backtests.service import Backtests, uid from ..backtests.service import Backtests, uid
from ..models import Account, ResearchFlowRun, ResearchStepRun, now from ..models import Account, ResearchFlowRun, ResearchStepRun, now
from .assets import Assets from .assets import Assets
from .evaluations import Evaluations from .evaluations import Evaluations
from .experiments import Experiments from .experiments import Experiments, scope_of
from .features import Features
from .model import request_model from .model import request_model
from .workflows import validate_graph from .workflows import validate_graph
from .workspace_contracts import AssetWrite, EvaluateInput, Expansion, Generation, TemplateSpec, WorkflowSpec from .workspace_contracts import (
AssetWrite,
EvaluateInput,
Expansion,
FeatureSpec,
Generation,
SettingVariants,
TemplateSpec,
WorkflowSpec,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTIVE = ("queued", "running") ACTIVE = ("queued", "running")
@@ -162,7 +173,8 @@ class ResearchRuntime:
model_work = await self.prepare(run_id) model_work = await self.prepare(run_id)
if model_work: if model_work:
step_id, context, revision = model_work step_id, context, revision = model_work
result, evidence = await request_model(self.ai, context, TemplateSpec, revision) output_type = FeatureSpec if context.get("method") == "feature" else TemplateSpec
result, evidence = await request_model(self.ai, context, output_type, revision)
await self.finish_model(run_id, step_id, result, evidence) await self.finish_model(run_id, step_id, result, evidence)
except HTTPException as exc: except HTTPException as exc:
await self.fail(run_id, str(exc.detail)) await self.fail(run_id, str(exc.detail))
@@ -271,7 +283,7 @@ class ResearchRuntime:
else None else None
) )
step.output = { step.output = {
**(previous.output if previous else {}), **(previous.output if previous else {"template": run.authorization.get("template")}),
"type": "context", "type": "context",
"input_ids": run.authorization["input_ids"], "input_ids": run.authorization["input_ids"],
} }
@@ -287,49 +299,44 @@ class ResearchRuntime:
} }
else: else:
return await self.reserve_model(db, run, step, data, node) return await self.reserve_model(db, run, step, data, node)
elif node.type == "feature":
reference = run.authorization.get("node_assets", {}).get(key)
if step.output.get("feature"):
ref = step.output["feature"]
reference = await Assets(db).get(ref["id"], ref["version"], "feature")
if reference:
template = await Features(db).to_template(reference["id"], reference["version"])
step.output = {
**data,
**step.output,
"type": "context",
"feature": {"id": reference["id"], "version": reference["version"]},
"input_ids": reference["content"]["input_ids"],
"template": {"id": template["id"], "version": template["version"]},
}
else:
return await self.reserve_model(db, run, step, data, node)
elif node.type == "expand": elif node.type == "expand":
if not data.get("template"): reference = run.authorization.get("node_assets", {}).get(key) or data.get("template")
raise HTTPException(422, "展开节点没有固定模板版本") await self.expand(db, run, step, {**data, "template": reference})
body = Expansion( return
asset_id=data["template"]["id"], elif node.type == "variant":
version=data["template"]["version"], if node.config.get("method", "structure") == "structure":
if step.output.get("template"):
await self.expand(db, run, step, step.output)
return
return await self.reserve_model(db, run, step, data, node)
experiment = await Experiments(db).setting_variants(
SettingVariants(
alpha_id=run.authorization["parent_alpha_ids"][0],
input_ids=run.authorization["input_ids"], input_ids=run.authorization["input_ids"],
hypothesis=run.authorization["hypothesis"], hypothesis=run.authorization["hypothesis"],
settings=run.authorization["settings"], ),
mode="random", parent_snapshot=run.authorization["parents"][0],
limit=run.authorization["batch_candidates"], extra_evidence={"flow_run_id": run.id, "node_id": key, "round": run.round},
seed=run.authorization["seed"] + run.round, kind=run.authorization["kind"],
parent_alpha_ids=run.authorization["parent_alpha_ids"],
parent_experiment_ids=data.get("parent_experiment_ids", []),
) )
frozen_parents = run.authorization["parents"] + await Experiments(db).parents( self.candidates(run, step, experiment, {})
[], body.parent_experiment_ids
)
experiment = await Experiments(db).create(
body,
"pipeline",
{
"flow_run_id": run.id,
"node_id": key,
"round": run.round,
"method": "pipeline",
"authorized_seed_snapshots": run.authorization["parents"],
},
parent_snapshots=frozen_parents,
)
step.output = {
"type": "candidates",
"experiment_id": experiment["id"],
"candidate_ids": [
c["client_item_id"]
for c in experiment["candidates"]
if c["validation"]["status"] == "valid"
],
"template": data["template"],
}
if not step.output["candidate_ids"]:
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
halt(run, "needs_review", step.error)
return return
elif node.type == "backtest": elif node.type == "backtest":
await self.backtest(db, run, step, data) await self.backtest(db, run, step, data)
@@ -349,6 +356,55 @@ class ResearchRuntime:
"experiment_id": data["experiment_id"], "experiment_id": data["experiment_id"],
"backtest_run_id": data["backtest_run_id"], "backtest_run_id": data["backtest_run_id"],
} }
elif node.type == "condition":
step.output = {**data, "type": "evaluation"}
elif node.type == "filter":
report = (await Evaluations(db).get(data["evaluation_id"]))["report"]
ids = [
r["client_item_id"]
for r in report["records"]
if r["verdict"] in node.config.get("verdicts", ["pass"])
]
experiment = await Experiments(db).get(data["experiment_id"])
template = experiment["evidence"].get("template")
step.output = {
**data,
"type": "candidates",
"candidate_ids": ids,
"template": {"id": template["id"], "version": template["version"]}
if template
else None,
}
if not ids:
step.status, step.updated_at = "skipped", now()
return
elif node.type == "summarize":
# References keep joins bounded, without recursively copying the upstream graph.
step.output = {
"type": "summary",
"artifacts": [
{
"step_id": steps[e.source].id,
"node_id": e.source,
**{
k: steps[e.source].output[k]
for k in (
"type",
"template",
"feature",
"experiment_id",
"evaluation_id",
"backtest_run_id",
"verdict",
)
if k in steps[e.source].output
},
}
for e in upstream_edges
if steps[e.source].status != "skipped"
and (not e.branch or steps[e.source].output.get("verdict") == e.branch)
],
}
elif node.type == "iterate": elif node.type == "iterate":
step.output = { step.output = {
**data, **data,
@@ -370,10 +426,16 @@ class ResearchRuntime:
generation = Generation( generation = Generation(
name=run.name, name=run.name,
hypothesis=run.authorization["hypothesis"], hypothesis=run.authorization["hypothesis"],
input_ids=run.authorization["input_ids"], input_ids=data.get("input_ids", run.authorization["input_ids"]),
method="feature"
if node.type == "feature"
else "structure"
if node.type == "variant"
else "template",
parent_experiment_ids=[data["experiment_id"]] if data.get("experiment_id") else [], parent_experiment_ids=[data["experiment_id"]] if data.get("experiment_id") else [],
) )
context = await Experiments(db).generation_context(generation) context = await Experiments(db).generation_context(generation)
context["method"] = generation.method
context["parents"] = run.authorization["parents"] + context["parents"] context["parents"] = run.authorization["parents"] + context["parents"]
context["operators"] = run.authorization["operators_snapshot"]["content"]["items"][:100] context["operators"] = run.authorization["operators_snapshot"]["content"]["items"][:100]
context["node_prompt"] = node.config.get("prompt", "") context["node_prompt"] = node.config.get("prompt", "")
@@ -414,9 +476,16 @@ class ResearchRuntime:
step = await db.get(ResearchStepRun, step_id) step = await db.get(ResearchStepRun, step_id)
if not step or step.status != "running": if not step or step.status != "running":
return return
if isinstance(result, FeatureSpec) and set(result.input_ids) != set(
[i["id"] for i in step.output["context"]["inputs"]]
):
raise HTTPException(422, "模型不能改变已固定的输入范围")
# A paused/stopped run may collect this already-issued model output, but cannot advance. # A paused/stopped run may collect this already-issued model output, but cannot advance.
asset = await Assets(db).save( asset = await Assets(db).save(
AssetWrite(kind="template", content=result.model_dump(mode="json")), AssetWrite(
kind="feature" if isinstance(result, FeatureSpec) else "template",
content=result.model_dump(mode="json"),
),
provenance={ provenance={
"flow_run_id": run_id, "flow_run_id": run_id,
"step_id": step_id, "step_id": step_id,
@@ -424,18 +493,92 @@ class ResearchRuntime:
"context": step.output["context"], "context": step.output["context"],
}, },
) )
feature = (
{"id": asset["id"], "version": asset["version"]} if isinstance(result, FeatureSpec) else None
)
mark_model_attempt(step, "completed") mark_model_attempt(step, "completed")
step.output = { step.output = {
"model_attempts": step.output.get("model_attempts", []), "model_attempts": step.output.get("model_attempts", []),
"type": "template", "type": "context" if feature else "template",
"template": {"id": asset["id"], "version": asset["version"]}, "feature": feature,
"input_ids": result.input_ids
if feature
else [i["id"] for i in step.output["context"]["inputs"]],
"template": None if feature else {"id": asset["id"], "version": asset["version"]},
"parent_experiment_ids": step.output.get("parent_experiment_ids", []), "parent_experiment_ids": step.output.get("parent_experiment_ids", []),
"generation": evidence, "generation": evidence,
"reserved_call": step.output["reserved_call"], "reserved_call": step.output["reserved_call"],
} }
step.status, step.updated_at = "completed", now() node = next(n for n in run.definition["nodes"] if n["id"] == step.node_id)
# Keep post-processing durable and separate: pause/account changes are checked again
# before converting features or expanding variants on the next active tick.
step.status = "generated" if node["type"] in ("variant", "feature") else "completed"
step.updated_at = now()
changed(run) changed(run)
def candidates(self, run, step, experiment, data):
step.output = {
**data,
"type": "candidates",
"experiment_id": experiment["id"],
"candidate_ids": [
c["client_item_id"] for c in experiment["candidates"] if c["validation"]["status"] == "valid"
],
}
ids = step.output["candidate_ids"]
maximum = run.authorization["batch_candidates"]
if len(ids) > maximum:
step.output = {
**step.output,
"candidate_ids": random.Random(run.authorization["seed"] + run.round).sample(ids, maximum),
}
step.status, step.updated_at = "completed", now()
if not step.output["candidate_ids"]:
step.status, step.error = "blocked", "候选均未通过本地校验,请核实字段、算子和设置"
if run.status in ACTIVE:
halt(run, "needs_review", step.error)
async def expand(self, db, run, step, data):
reference = data.get("template")
if not reference:
raise HTTPException(422, "展开节点没有固定模板版本")
scope = scope_of(SimulationSettings.model_validate(run.authorization["settings"]))
ids = [
i["id"]
for i in run.authorization["inputs"]
if i["scope"] == scope and i["id"] in data.get("input_ids", run.authorization["input_ids"])
]
if not ids:
raise HTTPException(422, "模板展开需要与基础设置匹配的固定输入")
body = Expansion(
asset_id=reference["id"],
version=reference["version"],
input_ids=ids,
hypothesis=run.authorization["hypothesis"],
settings=run.authorization["settings"],
mode="random",
limit=run.authorization["batch_candidates"],
seed=run.authorization["seed"] + run.round,
parent_alpha_ids=run.authorization["parent_alpha_ids"],
parent_experiment_ids=data.get("parent_experiment_ids", []),
)
frozen_parents = run.authorization["parents"] + await Experiments(db).parents(
[], body.parent_experiment_ids
)
experiment = await Experiments(db).create(
body,
run.authorization["kind"],
{
"flow_run_id": run.id,
"node_id": step.node_id,
"round": run.round,
"method": run.authorization["kind"],
"authorized_seed_snapshots": run.authorization["parents"],
},
parent_snapshots=frozen_parents,
)
self.candidates(run, step, experiment, data)
async def backtest(self, db, run, step, data): async def backtest(self, db, run, step, data):
service = Backtests(db) service = Backtests(db)
if step.backtest_run_id: if step.backtest_run_id:
@@ -470,12 +613,27 @@ class ResearchRuntime:
): ):
raise HTTPException(409, "研究候选预览不再匹配保存的授权步骤") raise HTTPException(409, "研究候选预览不再匹配保存的授权步骤")
experiment = await Experiments(db).get(step.output["experiment_id"]) experiment = await Experiments(db).get(step.output["experiment_id"])
if experiment["evidence"].get("flow_run_id") != run.id or { if (
s["id"] for s in experiment["inputs"] experiment["evidence"].get("flow_run_id") != run.id
} != set(run.authorization["input_ids"]): or {s["id"] for s in experiment["inputs"]}.issubset(set(run.authorization["input_ids"])) is False
):
raise HTTPException(403, "候选不属于此研究运行的固定输入范围") raise HTTPException(403, "候选不属于此研究运行的固定输入范围")
if "backtest" not in run.authorization["methods"] or any( if "backtest" not in run.authorization["methods"] or any(
c["settings"] != run.authorization["settings"] c["settings"]
not in (
run.authorization.get("allowed_settings", [run.authorization["settings"]])
if experiment["evidence"].get("method") == "settings"
else [run.authorization["settings"]]
)
or not c.get("input_ids")
or any(
not any(
i["id"] == input_id
and i["scope"] == scope_of(SimulationSettings.model_validate(c["settings"]))
for i in run.authorization["inputs"]
)
for input_id in c["input_ids"]
)
for c in experiment["candidates"] for c in experiment["candidates"]
if c["client_item_id"] in step.output["candidate_ids"] if c["client_item_id"] in step.output["candidate_ids"]
): ):
+67 -8
View File
@@ -5,7 +5,7 @@ from collections import defaultdict
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import func, select
from ..backtests.contracts import fingerprint from ..backtests.contracts import SimulationSettings, fingerprint
from ..backtests.service import uid from ..backtests.service import uid
from ..models import Account, ResearchFlowRun, ResearchStepRun from ..models import Account, ResearchFlowRun, ResearchStepRun
from .assets import Assets from .assets import Assets
@@ -64,8 +64,12 @@ def validate_graph(graph):
if node.type != "summarize" and len(incoming[node.id]) > 1: if node.type != "summarize" and len(incoming[node.id]) > 1:
raise HTTPException(422, "仅汇总节点接受多个上游;其他节点需要唯一输入") raise HTTPException(422, "仅汇总节点接受多个上游;其他节点需要唯一输入")
allowed = ( allowed = (
{"prompt"} {"prompt", "asset_id", "version"}
if node.type in ("feature", "generate") if node.type == "feature"
else {"asset_id", "version"}
if node.type == "expand"
else {"prompt"}
if node.type == "generate"
else {"method"} else {"method"}
if node.type == "variant" if node.type == "variant"
else {"verdicts"} else {"verdicts"}
@@ -76,6 +80,15 @@ def validate_graph(graph):
) )
if set(node.config) - allowed: if set(node.config) - allowed:
raise HTTPException(422, f"节点 {node.id} 包含不支持的配置") raise HTTPException(422, f"节点 {node.id} 包含不支持的配置")
if "asset_id" in node.config or "version" in node.config:
if (
not isinstance(node.config.get("asset_id"), str)
or not node.config["asset_id"]
or len(node.config["asset_id"]) > 36
or type(node.config.get("version")) is not int
or node.config["version"] < 1
):
raise HTTPException(422, "节点素材引用需要 ID 和正整数版本")
if "prompt" in node.config and ( if "prompt" in node.config and (
not isinstance(node.config["prompt"], str) or len(node.config["prompt"]) > 10000 not isinstance(node.config["prompt"], str) or len(node.config["prompt"]) > 10000
): ):
@@ -84,7 +97,7 @@ def validate_graph(graph):
raise HTTPException(422, "未知变体方法") raise HTTPException(422, "未知变体方法")
if node.type == "filter" and ( if node.type == "filter" and (
not isinstance(node.config.get("verdicts", ["pass"]), list) not isinstance(node.config.get("verdicts", ["pass"]), list)
or not set(node.config.get("verdicts", ["pass"])).issubset({"pass", "review", "block"}) or any(v not in ("pass", "review", "block") for v in node.config.get("verdicts", ["pass"]))
): ):
raise HTTPException(422, "筛选结果必须为 pass/review/block") raise HTTPException(422, "筛选结果必须为 pass/review/block")
if node.type == "iterate" and ( if node.type == "iterate" and (
@@ -169,8 +182,48 @@ class Workflows:
if node.type == "iterate" and node.config["max_rounds"] > body.budget.max_rounds: if node.type == "iterate" and node.config["max_rounds"] > body.budget.max_rounds:
raise HTTPException(422, "流程迭代上限超过本次授权轮数") raise HTTPException(422, "流程迭代上限超过本次授权轮数")
experiments = Experiments(self.db) experiments = Experiments(self.db)
inputs, _ = await experiments.inputs(body.input_ids, scope_of(body.settings)) settings_variant = any(
n.type == "variant" and n.config.get("method") == "settings" for n in graph.nodes
)
if settings_variant and len(body.parent_alpha_ids) != 1:
raise HTTPException(422, "设置变体需要且只能选择一个种子 Alpha")
if any(n.type == "variant" for n in graph.nodes) and not body.parent_alpha_ids:
raise HTTPException(422, "变体节点需要种子 Alpha")
inputs, _ = await experiments.inputs(
body.input_ids, None if settings_variant else scope_of(body.settings)
)
parents = await experiments.parents(body.parent_alpha_ids, []) parents = await experiments.parents(body.parent_alpha_ids, [])
allowed_settings = [body.settings.model_dump(mode="json")]
if settings_variant:
base = SimulationSettings.model_validate(parents[0]["settings"])
for snapshot in inputs:
scope = snapshot["scope"]
target = SimulationSettings.model_validate(
{
**base.model_dump(),
"instrumentType": scope["instrument_type"],
**{key: scope[key] for key in ("region", "universe", "delay")},
}
)
errors, _ = await experiments.settings_check(target)
if errors:
raise HTTPException(422, "目标范围:" + ";".join(errors))
if target.model_dump(mode="json") not in allowed_settings:
allowed_settings.append(target.model_dump(mode="json"))
node_assets = {}
for node in graph.nodes:
if node.config.get("asset_id"):
ref = await Assets(self.db).get(
node.config["asset_id"],
node.config["version"],
"feature" if node.type == "feature" else "template",
)
if node.type == "feature":
if not set(ref["content"]["input_ids"]).issubset(body.input_ids):
raise HTTPException(422, "特征方案输入超出本次固定范围")
if not ref["content"].get("template"):
raise HTTPException(422, "特征方案需要输出模板")
node_assets[node.id] = ref
errors, settings_snapshot = await experiments.settings_check(body.settings) errors, settings_snapshot = await experiments.settings_check(body.settings)
if errors: if errors:
raise HTTPException(422, ";".join(errors)) raise HTTPException(422, ";".join(errors))
@@ -187,6 +240,7 @@ class Workflows:
if ( if (
any( any(
node.type == "expand" node.type == "expand"
and not node.config.get("asset_id")
and any( and any(
e.target == node.id and next(n for n in graph.nodes if n.id == e.source).type == "input" e.target == node.id and next(n for n in graph.nodes if n.id == e.source).type == "input"
for e in graph.edges for e in graph.edges
@@ -212,6 +266,8 @@ class Workflows:
"template": template, "template": template,
"workflow": asset, "workflow": asset,
"methods": methods, "methods": methods,
"node_assets": node_assets,
"allowed_settings": allowed_settings,
"settings_snapshot": settings_snapshot, "settings_snapshot": settings_snapshot,
"operators_snapshot": operators_snapshot, "operators_snapshot": operators_snapshot,
"kind": "quantflow" if asset else "pipeline", "kind": "quantflow" if asset else "pipeline",
@@ -272,13 +328,16 @@ class Workflows:
} }
) )
async def list(self, limit=25, offset=0): async def list(self, limit=25, offset=0, kind=None):
query = select(ResearchFlowRun)
if kind:
query = query.where(ResearchFlowRun.authorization["kind"].as_string() == kind)
rows = await self.db.scalars( rows = await self.db.scalars(
select(ResearchFlowRun).order_by(ResearchFlowRun.created_at.desc()).limit(limit).offset(offset) query.order_by(ResearchFlowRun.created_at.desc()).limit(limit).offset(offset)
) )
return { return {
"items": [await self.get(row.id) for row in rows], "items": [await self.get(row.id) for row in rows],
"total": await self.db.scalar(select(func.count()).select_from(ResearchFlowRun)), "total": await self.db.scalar(select(func.count()).select_from(query.subquery())),
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
} }
+18
View File
@@ -184,6 +184,24 @@ async def list_flows(ctx, args):
CAPABILITIES += ( CAPABILITIES += (
Capability(
name="search_research_workflows",
schema=AssetQuery,
description="分页查阅 QuantFlow 原生流程与版本,不启动运行。",
label="搜索研究流程",
renderer="research",
effect="query",
handler=lambda ctx, args: Assets(ctx.business.db).list("workflow", **args.model_dump()),
),
Capability(
name="get_research_workflow",
schema=FixedAssetReference,
description="读取指定研究流程版本及原生节点连接,配合 get_research_run 解释执行产物。",
label="读取流程版本",
renderer="research",
effect="query",
handler=lambda ctx, args: Assets(ctx.business.db).get(args.asset_id, args.version, "workflow"),
),
Capability( Capability(
name="get_research_run", name="get_research_run",
schema=FlowReference, schema=FlowReference,
@@ -0,0 +1,121 @@
"""Stage-four 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_stage4_test"
RESTORE = "wq_research_restore_stage4"
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():
from unittest.mock import patch
import httpx
from sqlalchemy import select
from app.config import Settings
from app.main import create_app
from app.models import TemplateInput
from app.research.workspace_contracts import TemplateSpec
from tests.test_ai import configure
from tests.test_backtests import setup
from tests.test_quantflow import graph, launch, save
from tests.test_research_flows import drive
from tests.test_research_workspace import template
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:
assert (
await client.post(
"/api/v1/auth/login", json={"username": "admin", "password": "research-acceptance-only"}
)
).status_code == 200
# Configure a deterministic model; no provider or real platform network.
from tests.ai_fake import fake_model
app.state.ai.model_factory = fake_model
await configure(app, client)
platform, lane = await setup(app)
calls = []
async def model(ai, context, output_type, revision):
calls.append(context)
value = template()
value["expression"] = f"rank({{field}}) + {len(calls)}"
return TemplateSpec.model_validate(value), {
"model": "fixture",
"revision": revision,
"usage": {"requests": 1},
}
async with app.state.sessions() as db:
fixed = await db.scalar(select(TemplateInput))
body = {
"request_id": "finite-run",
"name": "PG 有限研究",
"input_ids": [fixed.id],
"hypothesis": "排名稳定性",
"settings": {"region": "USA", "universe": "TOP3000", "delay": 1},
"budget": {"max_rounds": 2, "max_simulations": 4, "max_model_calls": 3},
"batch_candidates": 2,
}
body["request_id"] = "pg-quantflow"
fixed_template = await save(client, "template", template())
definition = graph("input", "expand", "backtest", "evaluate", "filter", "iterate")
definition["nodes"][1]["config"] = {"asset_id": fixed_template["id"], "version": 1}
definition["nodes"][4]["config"] = {"verdicts": ["pass", "review", "block"]}
with patch("app.research.runtime.request_model", model):
run = await launch(client, body, definition)
result = await drive(app, client, run["id"], lane, ticks=60)
assert result["status"] == "completed", result
assert (
result["round"] == 2
and result["simulations_used"] == 4
and result["model_calls_used"] == 0
)
assert not calls and len(platform.posts) == 2
await app.state.research.recover()
await app.state.research.advance(run["id"])
assert len(platform.posts) == 2
print("PASS PostgreSQL: versioned native graph, filtering, bounded iteration and replay")
if __name__ == "__main__":
docker("createdb", "-U", "postgres", NAME)
with Path("/tmp/wq-research-stage3.dump").open("rb") as source:
docker("pg_restore", "-U", "postgres", "-d", NAME, stdin=source)
config = Config("alembic.ini")
command.upgrade(config, "0008")
command.check(config)
asyncio.run(acceptance())
dump = Path("/tmp/wq-research-stage4.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 md5(string_agg(row_to_json(t)::text, '' ORDER BY id)) FROM research_flow_runs t),(SELECT md5(string_agg(row_to_json(t)::text, '' ORDER BY id)) FROM research_step_runs t),(SELECT count(*) FROM research_revisions),(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: head 0008 unchanged, pg_dump/pg_restore preserves complete run/step snapshots and old research notes"
)
+286
View File
@@ -0,0 +1,286 @@
"""Native graph execution shares immutable inputs, artifacts and budgeted backtests."""
import copy
import pytest
from app.alphas import upsert_alpha
from app.models import Alpha, CatalogResource
from app.research.workspace_contracts import FeatureSpec
from tests.conftest import alpha
from tests.test_catalog import SCOPE, prepare, sync
from tests.test_research_flows import begin, drive, flow_setup, get
from tests.test_research_workspace import catalog, research_input, template
__all__ = ["flow_setup", "catalog", "research_input"]
def graph(*kinds):
return {
"name": "原生节点研究",
"nodes": [
{
"id": f"n{i}",
"type": kind,
"label": kind,
"config": {"max_rounds": 2} if kind == "iterate" else {},
}
for i, kind in enumerate(kinds)
],
"edges": [{"source": f"n{i}", "target": f"n{i + 1}"} for i in range(len(kinds) - 1)],
}
async def save(client, kind, content):
r = await client.post("/api/v1/research/assets", json={"kind": kind, "content": content})
assert r.status_code == 201, r.text
return r.json()
async def launch(client, body, definition):
asset = await save(client, "workflow", definition)
return await begin(client, {**body, "workflow_id": asset["id"], "workflow_version": asset["version"]})
@pytest.mark.parametrize(
"case", ["cycle", "type", "duplicate", "disconnected", "loop", "config", "filter", "ref"]
)
async def test_invalid_graphs_rejected_before_save(logged_in, case):
value = graph("input", "generate", "expand", "backtest", "evaluate")
if case == "cycle":
value["nodes"] += [{"id": "loop_a", "type": "generate"}, {"id": "loop_b", "type": "expand"}]
value["edges"] += [{"source": "loop_a", "target": "loop_b"}, {"source": "loop_b", "target": "loop_a"}]
if case == "type":
value["edges"][0]["target"] = "n3"
if case == "duplicate":
value["edges"].append(value["edges"][0])
if case == "disconnected":
value["edges"].pop()
if case == "loop":
value["nodes"].append({"id": "iterate", "type": "iterate", "config": {"max_rounds": True}})
value["edges"].append({"source": "n4", "target": "iterate"})
if case == "config":
value["nodes"][1]["config"] = {"command": "bash"}
if case == "filter":
value["nodes"].append({"id": "filter", "type": "filter", "config": {"verdicts": [{}]}})
value["edges"].append({"source": "n4", "target": "filter"})
if case == "ref":
value["nodes"][2]["config"] = {"asset_id": "x", "version": True}
for url in ("/api/v1/research/flows/validate", "/api/v1/research/assets"):
r = await logged_in.post(
url, json=value if url.endswith("validate") else {"kind": "workflow", "content": value}
)
assert r.status_code == 422, r.text
async def test_versioned_template_branch_join_and_replayed_events(app, logged_in, flow_setup):
body, platform, lane, calls = flow_setup
fixed = await save(logged_in, "template", template())
definition = graph("input", "expand", "backtest", "evaluate", "condition")
definition["nodes"][1]["config"] = {"asset_id": fixed["id"], "version": 1}
for verdict in ("pass", "review", "block"):
definition["nodes"].append({"id": verdict, "type": "summarize"})
definition["edges"].append({"source": "n4", "target": verdict, "branch": verdict})
# A shared join receives the condition's evidence as well as direct evaluation.
definition["nodes"].append({"id": "summary", "type": "summarize"})
definition["edges"] += [{"source": "n4", "target": "summary"}, {"source": "n3", "target": "summary"}]
run = await launch(logged_in, body, definition)
workflow = run["authorization"]["workflow"]
modified = copy.deepcopy(definition)
modified["nodes"][1]["config"]["version"] = 2
assert (
await logged_in.put(
f"/api/v1/research/assets/{workflow['id']}",
json={"kind": "workflow", "version": 1, "content": modified},
)
).status_code == 200
value = template()
value["expression"] = "unknown({field})"
assert (
await logged_in.put(
f"/api/v1/research/assets/{fixed['id']}",
json={"kind": "template", "version": 1, "content": value},
)
).status_code == 200
final = await drive(app, logged_in, run["id"], lane)
assert final["status"] == "completed", final
assert final["model_calls_used"] == 0 and not calls and final["simulations_used"] == 2
branches = [s for s in final["steps"] if s["node_id"] in ("pass", "review", "block")]
verdict = next(s for s in final["steps"] if s["node_id"] == "n3")["output"]["verdict"]
assert [s["node_id"] for s in branches if s["status"] == "completed"] == [verdict]
assert sum(s["status"] == "skipped" for s in branches) == 2
assert len(next(s for s in final["steps"] if s["node_id"] == "summary")["output"]["artifacts"]) == 2
assert final["authorization"]["node_assets"]["n1"]["version"] == 1
for _ in range(3):
await app.state.research.advance(run["id"])
assert len(platform.posts) == 1
assert (await logged_in.get("/api/v1/research/flows/runs?kind=quantflow")).json()["total"] == 1
assert (await logged_in.get("/api/v1/research/flows/runs?kind=pipeline")).json()["total"] == 0
async def test_feature_reference_conversion_and_bounded_loop(app, logged_in, flow_setup):
body, _, lane, calls = flow_setup
feature = await save(
logged_in,
"feature",
{
"name": "字段方案",
"hypothesis": "排名稳定性",
"input_ids": body["input_ids"],
"steps": [{"name": "排名", "rationale": "截面比较", "expression": "rank(TEST_FIN_001)"}],
"template": template(),
},
)
definition = graph("input", "feature", "expand", "backtest", "evaluate", "filter", "iterate")
definition["nodes"][1]["config"] = {"asset_id": feature["id"], "version": 1}
definition["nodes"][5]["config"] = {"verdicts": ["pass", "review", "block"]}
run = await launch(logged_in, body, definition)
final = await drive(app, logged_in, run["id"], lane, ticks=60)
assert final["status"] == "completed", final
assert final["round"] == 2 and final["simulations_used"] == 4 and not calls
outputs = [s["output"] for s in final["steps"] if s["node_id"] == "n1"]
assert all(o["feature"]["id"] == feature["id"] for o in outputs)
template_asset = (await logged_in.get(f"/api/v1/research/assets/{outputs[0]['template']['id']}")).json()
assert template_asset["provenance"]["feature"]["version"] == 1
async def test_native_feature_model_and_structure_variant(app, logged_in, flow_setup, monkeypatch):
body, _, lane, calls = flow_setup
async def feature_model(ai, context, output_type, revision):
assert output_type is FeatureSpec
calls.append(context)
return FeatureSpec.model_validate(
{
"name": "模型特征",
"hypothesis": "排名",
"input_ids": body["input_ids"],
"steps": [{"name": "排名", "rationale": "比较", "expression": "rank(TEST_FIN_001)"}],
"template": template(),
}
), {"model": "fixture", "revision": revision}
with monkeypatch.context() as patch:
patch.setattr("app.research.runtime.request_model", feature_model)
run = await launch(logged_in, body, graph("input", "feature", "expand", "backtest", "evaluate"))
final = await drive(app, logged_in, run["id"], lane)
assert final["status"] == "completed" and final["model_calls_used"] == 1, final
assert final["steps"][1]["output"]["feature"]
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", regular={"code": "rank(TEST_FIN_001)"}))
run = await launch(
logged_in,
{**body, "request_id": "variant", "parent_alpha_ids": ["seed"]},
graph("input", "variant", "backtest", "evaluate"),
)
final = await drive(app, logged_in, run["id"], lane)
assert final["status"] == "completed" and final["model_calls_used"] == 1, final
experiment = (
await logged_in.get(f"/api/v1/research/experiments/{final['steps'][1]['output']['experiment_id']}")
).json()
assert experiment["parents"][0]["id"] == "seed" and experiment["kind"] == "quantflow"
async def test_settings_variant_uses_frozen_seed_and_target_scope(app, logged_in, research_input, catalog):
from tests.test_backtests import setup
body = {
"request_id": "scope-run",
"name": "范围变体",
"input_ids": [research_input["id"]],
"hypothesis": "跨股票池比较",
"settings": {"region": "USA", "universe": "TOP3000", "delay": 1},
"budget": {"max_rounds": 1, "max_simulations": 2, "max_model_calls": 1},
}
target = {**SCOPE, "universe": "TOP1000"}
await sync(catalog, scope=target)
version = (await sync(catalog, "TEST_FIN", scope=target))["id"]
fixed = (await prepare(logged_in, version, scope=target)).json()
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", regular={"code": "x = TEST_FIN_001; rank(x)"}))
metadata = await db.get(CatalogResource, "settings")
metadata.content = {
"items": metadata.content["items"] + [{**target, "neutralizations": ["INDUSTRY"]}]
}
_, lane = await setup(app)
definition = graph("input", "variant", "backtest", "evaluate")
definition["nodes"][1]["config"] = {"method": "settings"}
run = await launch(
logged_in,
{**body, "input_ids": body["input_ids"] + [fixed["id"]], "parent_alpha_ids": ["seed"]},
definition,
)
async with app.state.sessions.begin() as db:
seed = await db.get(Alpha, "seed")
seed.expression = "rank(unknown)"
final = await drive(app, logged_in, run["id"], lane)
assert (
final["status"] == "completed" and final["simulations_used"] == 1 and final["model_calls_used"] == 0
), final
experiment = (
await logged_in.get(f"/api/v1/research/experiments/{final['steps'][1]['output']['experiment_id']}")
).json()
c = experiment["candidates"][0]
assert c["expression"] == "x = TEST_FIN_001; rank(x)" and c["input_ids"] == [fixed["id"]]
assert c["settings"]["universe"] == "TOP1000" and len(experiment["inputs"]) == 2
async def test_empty_filter_skips_downstream_simulation(app, logged_in, flow_setup):
body, platform, lane, _ = flow_setup
definition = graph(
"input", "generate", "expand", "backtest", "evaluate", "filter", "backtest", "evaluate"
)
definition["nodes"][5]["config"] = {"verdicts": []}
run = await launch(logged_in, body, definition)
final = await drive(app, logged_in, run["id"], lane)
assert final["status"] == "completed" and len(platform.posts) == 1, final
assert [s["status"] for s in final["steps"][-3:]] == ["skipped"] * 3
@pytest.mark.parametrize("action", ["pause", "stop", "account"])
async def test_collected_variant_output_waits_for_authorized_next_tick(app, logged_in, flow_setup, action):
from sqlalchemy import func, select
from app.models import Account, ResearchExperiment
from app.research.workspace_contracts import TemplateSpec
body, platform, lane, _ = flow_setup
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha("seed", regular={"code": "rank(TEST_FIN_001)"}))
run = await launch(
logged_in, {**body, "parent_alpha_ids": ["seed"]}, graph("input", "variant", "backtest", "evaluate")
)
await app.state.research.advance(run["id"])
work = await app.state.research.prepare(run["id"])
current = await get(logged_in, run["id"])
if action == "account":
async with app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.wq_user_id = "OTHER_ACCOUNT"
else:
assert (
await logged_in.post(
f"/api/v1/research/flows/runs/{run['id']}/control",
json={"action": action, "version": current["version"]},
)
).status_code == 200
await app.state.research.finish_model(
run["id"], work[0], TemplateSpec.model_validate(template()), {"model": "fixture"}
)
await app.state.research.advance(run["id"])
current = await get(logged_in, run["id"])
assert current["steps"][1]["status"] == "generated" and current["model_calls_used"] == 1
assert current["status"] == {"pause": "paused", "stop": "stopped", "account": "interrupted"}[action]
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(ResearchExperiment)) == 0
assert not platform.posts
if action == "pause":
assert (
await logged_in.post(
f"/api/v1/research/flows/runs/{run['id']}/control",
json={"action": "resume", "version": current["version"]},
)
).status_code == 200
await app.state.research.recover()
final = await drive(app, logged_in, run["id"], lane)
assert final["status"] == "completed" and final["model_calls_used"] == 1
+22 -3
View File
@@ -12,6 +12,7 @@ import {
} from "@douyinfe/semi-ui-19"; } from "@douyinfe/semi-ui-19";
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN"; import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons"; import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
import { QuantFlowPage } from "./research/QuantFlowPage";
import { PipelinePage } from "./research/PipelinePage"; import { PipelinePage } from "./research/PipelinePage";
import { FeaturesPage } from "./research/FeaturesPage"; import { FeaturesPage } from "./research/FeaturesPage";
import { ResearchWorkspace } from "./research/ResearchWorkspace"; import { ResearchWorkspace } from "./research/ResearchWorkspace";
@@ -52,9 +53,14 @@ export default function App() {
}); });
useEffect(() => { useEffect(() => {
if ( if (
["operators", "templates", "variants", "features", "pipeline"].includes( [
page, "operators",
) "templates",
"variants",
"features",
"pipeline",
"quantflow",
].includes(page)
) )
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page])); setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
}, [page]); }, [page]);
@@ -376,6 +382,15 @@ export default function App() {
/> />
)} )}
</div> </div>
{visitedResearch.includes("quantflow") && (
<div className="alpha-page-view" hidden={page !== "quantflow"}>
<QuantFlowPage
active={page === "quantflow"}
onAction={handleAction}
onContext={setResearchContext}
/>
</div>
)}
{visitedResearch.includes("pipeline") && ( {visitedResearch.includes("pipeline") && (
<div className="alpha-page-view" hidden={page !== "pipeline"}> <div className="alpha-page-view" hidden={page !== "pipeline"}>
<PipelinePage <PipelinePage
@@ -462,6 +477,10 @@ export default function App() {
onClose={() => setChatOpen(false)} onClose={() => setChatOpen(false)}
context={ context={
{ {
quantflow:
researchContext.page === "quantflow"
? researchContext
: { page: "quantflow" as const },
pipeline: pipeline:
researchContext.page === "pipeline" researchContext.page === "pipeline"
? researchContext ? researchContext
+2 -1
View File
@@ -20,7 +20,8 @@ export type PageContext = {
| "templates" | "templates"
| "variants" | "variants"
| "features" | "features"
| "pipeline"; | "pipeline"
| "quantflow";
research_run_id?: string | null; research_run_id?: string | null;
research_asset_id?: string; research_asset_id?: string;
research_experiment_id?: string; research_experiment_id?: string;
+2
View File
@@ -7,6 +7,8 @@ const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
operators: () => "上下文:算子库", operators: () => "上下文:算子库",
templates: (context) => templates: (context) =>
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`, `上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
quantflow: (context) =>
`上下文:QuantFlow${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
pipeline: (context) => pipeline: (context) =>
`上下文:研究流水线${context.research_run_id ? ` · ${context.research_run_id}` : ""}`, `上下文:研究流水线${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
features: () => "上下文:特征工程", features: () => "上下文:特征工程",
+1
View File
@@ -28,6 +28,7 @@ const navigation = [
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" }, { id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" }, { id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" }, { id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" }, { id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
{ id: "account", label: "个人信息", icon: IconUser, group: "" }, { id: "account", label: "个人信息", icon: IconUser, group: "" },
] as const; ] as const;
+246
View File
@@ -0,0 +1,246 @@
import { useRef, useState } from "react";
import { Button } from "@douyinfe/semi-ui-19";
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
import { flowStatus } from "./flowTypes";
export type NodeType = {
type: string;
label: string;
accepts: string[];
produces: string;
};
export function FlowCanvas({
graph,
types,
selected,
onSelect,
onChange,
run,
}: {
graph: Workflow;
types: NodeType[];
selected: string | null;
onSelect: (id: string) => void;
onChange?: (graph: Workflow) => void;
run?: FlowRun | null;
}) {
const [zoom, setZoom] = useState(0.8);
const viewport = useRef<HTMLDivElement>(null);
const portNames: Record<string, string> = {
context: "输入范围",
template: "模板",
candidates: "候选表达式",
results: "回测结果",
evaluation: "评估报告",
summary: "研究汇总",
iteration: "迭代产物",
};
const drag = useRef<{
id: string;
x: number;
y: number;
left: number;
top: number;
} | null>(null);
const width = Math.max(1120, ...graph.nodes.map((n) => n.x + 260));
const height = Math.max(520, ...graph.nodes.map((n) => n.y + 180));
function move(node: FlowNode, x: number, y: number) {
onChange?.({
...graph,
nodes: graph.nodes.map((n) =>
n.id === node.id
? {
...n,
x: Math.max(0, Math.min(10000, x)),
y: Math.max(0, Math.min(10000, y)),
}
: n,
),
});
}
return (
<div className="flow-canvas-shell">
<div className="flow-canvas-toolbar">
<span>
{onChange
? "拖动节点排列 · 选中后可用方向键微调"
: "节点颜色与状态显示当前轮次"}
</span>
<div className="inline-actions">
<Button
size="small"
aria-label="缩小画布"
onClick={() => setZoom((z) => Math.max(0.4, z - 0.1))}
>
−
</Button>
<span>{Math.round(zoom * 100)}%</span>
<Button
size="small"
aria-label="放大画布"
onClick={() => setZoom((z) => Math.min(1.5, z + 0.1))}
>
+
</Button>
<Button size="small" onClick={() => setZoom(0.8)}>
重置缩放
</Button>
<Button
size="small"
onClick={() =>
setZoom(
Math.max(
0.25,
Math.min(1, (viewport.current?.clientWidth || width) / width),
),
)
}
>
适应画布
</Button>
</div>
</div>
<div
ref={viewport}
className="flow-canvas-viewport"
aria-label="QuantFlow 画布"
>
<div style={{ width: width * zoom, height: height * zoom }}>
<div
className="flow-canvas"
style={{
width,
height,
transform: `scale(${zoom})`,
transformOrigin: "top left",
}}
>
<svg
width={width}
height={height}
className="flow-edges"
aria-label="节点连线"
>
{graph.edges.map((e, index) => {
const a = graph.nodes.find((n) => n.id === e.source),
b = graph.nodes.find((n) => n.id === e.target);
if (!a || !b) return null;
const x = a.x + 200,
y = a.y + 50,
tx = b.x,
ty = b.y + 50;
const sibling = graph.edges
.filter((edge) => edge.source === e.source)
.indexOf(e);
const routed = Boolean(e.branch) || tx < x || tx - x > 130;
const lane =
tx < x
? a.y + 130 + sibling * 14
: Math.max(10, Math.min(a.y, b.y) - 30 - sibling * 14);
const path = routed
? `M${x} ${y} H${x + 18} V${lane} H${tx - 18} V${ty} H${tx}`
: `M${x} ${y} C${x + 30} ${y}, ${tx - 30} ${ty}, ${tx} ${ty}`;
const upstream = run?.steps.find(
(step) =>
step.node_id === e.source && step.round === run.round,
);
const inactive =
upstream &&
(upstream.status === "skipped" ||
(e.branch && upstream.output.verdict !== e.branch));
return (
<g
key={`${e.source}-${e.target}-${index}`}
opacity={inactive ? 0.3 : 1}
>
<path d={path} />
<path
d={`M${tx - 8} ${ty - 5} L${tx} ${ty} L${tx - 8} ${ty + 5}`}
/>
{e.branch && (
<text
x={(x + tx) / 2}
y={routed ? lane - 5 : (y + ty) / 2 - 8}
>
{e.branch}
</text>
)}
</g>
);
})}
</svg>
{graph.nodes.map((node) => {
const type = types.find((t) => t.type === node.type);
const step = run?.steps.find(
(s) => s.node_id === node.id && s.round === run.round,
);
return (
<button
key={node.id}
type="button"
aria-label={`节点 ${node.label || node.id}`}
aria-pressed={selected === node.id}
className={`flow-node ${selected === node.id ? "selected" : ""} ${step?.status || ""}`}
style={{ left: node.x, top: node.y, touchAction: "none" }}
onClick={() => onSelect(node.id)}
onPointerDown={(e) => {
onSelect(node.id);
if (!onChange) return;
drag.current = {
id: node.id,
x: e.clientX,
y: e.clientY,
left: node.x,
top: node.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = drag.current;
if (d?.id === node.id)
move(
node,
d.left + (e.clientX - d.x) / zoom,
d.top + (e.clientY - d.y) / zoom,
);
}}
onPointerUp={() => {
drag.current = null;
}}
onPointerCancel={() => {
drag.current = null;
}}
onKeyDown={(e) => {
const delta: Record<string, [number, number]> = {
ArrowLeft: [-10, 0],
ArrowRight: [10, 0],
ArrowUp: [0, -10],
ArrowDown: [0, 10],
};
if (onChange && delta[e.key]) {
e.preventDefault();
move(
node,
node.x + delta[e.key][0],
node.y + delta[e.key][1],
);
}
}}
>
<span className="flow-node-kind">
{type?.label || node.type}
</span>
<strong>{node.label || node.id}</strong>
<span>
{step
? flowStatus[step.status] || step.status
: `${type && type.accepts.length > 2 ? "多种研究产物" : type?.accepts.map((p) => portNames[p] || p).join(" / ") || "固定数据"} → ${portNames[type?.produces || ""] || "产物"}`}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
+32 -1
View File
@@ -24,7 +24,7 @@ export function FlowLaunchForm({
const [templates, setTemplates] = useState<Asset[]>([]); const [templates, setTemplates] = useState<Asset[]>([]);
const [ids, setIds] = useState<string[]>([]); const [ids, setIds] = useState<string[]>([]);
const [template, setTemplate] = useState<string>(); const [template, setTemplate] = useState<string>();
const [name, setName] = useState("固定研究流水线"); const [name, setName] = useState(workflow?.name || "固定研究流水线");
const [hypothesis, setHypothesis] = useState(""); const [hypothesis, setHypothesis] = useState("");
const [parent, setParent] = useState(""); const [parent, setParent] = useState("");
const [budget, setBudget] = useState<Budget>({ const [budget, setBudget] = useState<Budget>({
@@ -284,10 +284,41 @@ export function FlowLaunchForm({
<p> <p>
{confirmation.name}:{confirmation.hypothesis} {confirmation.name}:{confirmation.hypothesis}
</p> </p>
{workflow && (
<p>
流程:{workflow.name} · v{workflow.version}
</p>
)}
<p> <p>
{confirmation.input_ids.length} 个固定输入 · {settings.region}/ {confirmation.input_ids.length} 个固定输入 · {settings.region}/
{settings.universe}/D{settings.delay} {settings.universe}/D{settings.delay}
</p> </p>
<ul>
{confirmation.input_ids.map((id) => {
const input = inputs.find((i) => i.id === id);
return (
<li key={id}>
{input
? `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay}`
: id}{" "}
· {id.slice(0, 8)}
</li>
);
})}
</ul>
{confirmation.parent_alpha_ids.length > 0 && (
<p>
种子 Alpha:{confirmation.parent_alpha_ids.join("、")}
。市场与设置变体保持种子表达式及其他设置,仅使用上述固定输入的目标范围。
</p>
)}
{confirmation.template_id && (
<p>
初始模板:
{templates.find((t) => t.id === confirmation.template_id)?.name}{" "}
· v{confirmation.template_version}
</p>
)}
<p> <p>
最多 {confirmation.budget.max_rounds} 轮、 最多 {confirmation.budget.max_rounds} 轮、
{confirmation.budget.max_simulations} 条模拟、 {confirmation.budget.max_simulations} 条模拟、
+1 -1
View File
@@ -36,7 +36,7 @@ export function PipelinePage({
async function refresh() { async function refresh() {
try { try {
const list = await api<{ items: FlowRun[]; total: number }>( const list = await api<{ items: FlowRun[]; total: number }>(
`/research/flows/runs?offset=${(page - 1) * 25}`, `/research/flows/runs?kind=pipeline&offset=${(page - 1) * 25}`,
{ signal: c.signal }, { signal: c.signal },
); );
setItems(list.items); setItems(list.items);
+775
View File
@@ -0,0 +1,775 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Pagination,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { FlowCanvas, type NodeType } from "./FlowCanvas";
import { FlowLaunchForm } from "./FlowLaunchForm";
import { FlowRunView } from "./FlowRunView";
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
import { flowStatus } from "./flowTypes";
import "./workspace.css";
type WorkflowAsset = {
id: string;
version: number;
name: string;
content: Workflow;
};
type RefAsset = { id: string; version: number; name: string; kind: string };
const empty: Workflow = {
name: "新研究流程",
nodes: [
{ id: "input", type: "input", label: "固定输入", x: 40, y: 80, config: {} },
],
edges: [],
};
function readDraft(key: string, fallback: Workflow): Workflow {
try {
const value = JSON.parse(
localStorage.getItem(`quantflow-draft:${key}`) || "null",
);
const graph = value?.graph || value;
return graph?.nodes && graph?.edges && graph?.name ? graph : fallback;
} catch {
return fallback;
}
}
export function QuantFlowPage({
active,
onContext,
onAction,
}: {
active: boolean;
onContext: (c: PageContext) => void;
onAction: (a: UIAction) => void;
}) {
const [assets, setAssets] = useState<WorkflowAsset[]>([]),
[total, setTotal] = useState(0),
[page, setPage] = useState(1);
const [types, setTypes] = useState<NodeType[]>([]),
[refs, setRefs] = useState<RefAsset[]>([]);
const [asset, setAsset] = useState<WorkflowAsset | null>(null),
[versions, setVersions] = useState<{ version: number }[]>([]);
const [graph, setGraph] = useState<Workflow>(() => readDraft("new", empty));
const [selected, setSelected] = useState<string | null>("input"),
[nodeType, setNodeType] = useState("generate");
const [source, setSource] = useState(""),
[target, setTarget] = useState(""),
[branch, setBranch] = useState("");
const [error, setError] = useState(""),
[valid, setValid] = useState(false),
[busy, setBusy] = useState(false);
const [mode, setMode] = useState<"edit" | "launch" | "runs">(() =>
localStorage.getItem("quantflow-mode") === "runs" ? "runs" : "edit",
);
const [runs, setRuns] = useState<FlowRun[]>([]),
[runTotal, setRunTotal] = useState(0),
[runPage, setRunPage] = useState(1);
const [runId, setRunId] = useState<string | null>(() =>
localStorage.getItem("quantflow-selected-run"),
);
const [run, setRun] = useState<FlowRun | null>(null),
[revision, setRevision] = useState(0);
useEffect(() => {
localStorage.setItem("quantflow-mode", mode === "runs" ? "runs" : "edit");
}, [mode]);
const node = graph.nodes.find((n) => n.id === selected);
const dirty =
!asset || JSON.stringify(graph) !== JSON.stringify(asset.content);
useEffect(() => {
if (active)
onContext({
page: "quantflow",
research_asset_id: asset?.id,
research_run_id: mode === "runs" ? runId : null,
});
}, [active, asset?.id, runId, mode, onContext]);
useEffect(() => {
try {
localStorage.setItem(
`quantflow-draft:${asset?.id || "new"}`,
JSON.stringify({ graph, asset }),
);
} catch {
/* The in-memory draft remains usable when local storage is full. */
}
}, [graph, asset?.id]);
useEffect(() => {
if (!active) return;
const c = new AbortController();
Promise.all([
api<{ items: WorkflowAsset[]; total: number }>(
`/research/assets?kind=workflow&offset=${(page - 1) * 25}`,
{ signal: c.signal },
),
api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }),
api<{ items: RefAsset[] }>("/research/assets?kind=feature&limit=100", {
signal: c.signal,
}),
api<{ items: RefAsset[] }>("/research/assets?kind=template&limit=100", {
signal: c.signal,
}),
])
.then(([a, t, f, m]) => {
setAssets(a.items);
setTotal(a.total);
setTypes(t.items);
setRefs([...f.items, ...m.items]);
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [active, page, revision]);
useEffect(() => {
if (!active || mode !== "runs") return;
const c = new AbortController();
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const list = await api<{ items: FlowRun[]; total: number }>(
`/research/flows/runs?kind=quantflow&offset=${(runPage - 1) * 25}`,
{ signal: c.signal },
);
setRuns(list.items);
setRunTotal(list.total);
if (runId)
setRun(
await api(`/research/flows/runs/${runId}`, { signal: c.signal }),
);
} catch (e) {
if (!c.signal.aborted) setError((e as Error).message);
} finally {
if (!c.signal.aborted) timer = setTimeout(() => void refresh(), 1500);
}
}
void refresh();
return () => {
c.abort();
clearTimeout(timer);
};
}, [active, mode, runId, runPage, revision]);
function change(value: Workflow) {
setGraph(value);
setValid(false);
setError("");
}
async function open(item: WorkflowAsset, version?: number) {
try {
const loaded = version
? await api<WorkflowAsset>(
`/research/assets/${item.id}?version=${version}`,
)
: item;
let baseline = loaded;
if (!version) {
try {
const draft = JSON.parse(
localStorage.getItem(`quantflow-draft:${loaded.id}`) || "null",
);
if (draft?.asset?.id === loaded.id) baseline = draft.asset;
} catch {
/* Keep the server version when the local draft is unavailable. */
}
}
setAsset(baseline);
change(version ? loaded.content : readDraft(loaded.id, loaded.content));
setSelected(loaded.content.nodes[0]?.id || null);
setMode("edit");
setVersions(await api(`/research/assets/${item.id}/versions`));
} catch (e) {
setError((e as Error).message);
}
}
async function validate(value = graph) {
await post("/research/flows/validate", value);
setValid(true);
setError("");
}
async function save(copy = false) {
setBusy(true);
try {
await validate();
const result = await api<WorkflowAsset>(
asset && !copy ? `/research/assets/${asset.id}` : "/research/assets",
{
method: asset && !copy ? "PUT" : "POST",
body: JSON.stringify({
kind: "workflow",
content: graph,
...(asset && !copy ? { version: asset.version } : {}),
}),
},
);
setAsset(result);
setGraph(result.content);
setValid(true);
setVersions(await api(`/research/assets/${result.id}/versions`));
setRevision((v) => v + 1);
Toast.success(`已保存流程 v${result.version}`);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function updateNode(config: Partial<FlowNode>) {
if (node)
change({
...graph,
nodes: graph.nodes.map((n) =>
n.id === node.id ? { ...n, ...config } : n,
),
});
}
function addNode() {
const type = types.find((t) => t.type === nodeType);
if (!type || graph.nodes.length >= 50) return;
const id = `${nodeType}_${crypto.randomUUID().slice(0, 8)}`;
change({
...graph,
nodes: [
...graph.nodes,
{
id,
type: nodeType,
label: type.label,
x: 60 + (graph.nodes.length % 4) * 250,
y: 80 + Math.floor(graph.nodes.length / 4) * 180,
config:
nodeType === "iterate"
? { max_rounds: 3 }
: nodeType === "variant"
? { method: "structure" }
: {},
},
],
});
setSelected(id);
}
function addEdge() {
const a = graph.nodes.find((n) => n.id === source),
b = graph.nodes.find((n) => n.id === target);
if (!a || !b || source === target) {
setError("请选择两个不同的连线端点");
return;
}
if (
!types
.find((t) => t.type === b.type)
?.accepts.includes(types.find((t) => t.type === a.type)?.produces || "")
) {
setError("输入输出类型不兼容");
return;
}
const edge = {
source,
target,
branch: a.type === "condition" && branch ? branch : null,
};
if (
graph.edges.some(
(e) =>
e.source === source &&
e.target === target &&
e.branch === edge.branch,
)
) {
setError("连线重复");
return;
}
change({ ...graph, edges: [...graph.edges, edge] });
}
return (
<section className="research-workspace quantflow-workspace">
<div className="section-toolbar">
<div>
<h2>QuantFlow</h2>
<p className="muted">组合原生研究节点,复用回测、评估与有限预算。</p>
</div>
<div className="inline-actions">
<Button onClick={() => setMode("edit")}>编辑画布</Button>
<Button onClick={() => setMode("runs")}>运行记录</Button>
<Button
onClick={() => {
setAsset(null);
setVersions([]);
change(readDraft("new", empty));
setMode("edit");
}}
>
新建流程
</Button>
<Button
onClick={async () => {
try {
const recipe = await api<Workflow>("/research/flows/recipe");
setAsset(null);
setVersions([]);
change({ ...recipe, name: "固定研究配方副本" });
setMode("edit");
} catch (e) {
setError((e as Error).message);
}
}}
>
复制固定配方
</Button>
</div>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<h3>{mode === "runs" ? "画布运行" : "流程库"}</h3>
{mode === "runs" ? (
<>
{runs.map((r) => (
<button
className={`research-library-item ${runId === r.id ? "selected" : ""}`}
key={r.id}
onClick={() => {
setRunId(r.id);
setRun(null);
localStorage.setItem("quantflow-selected-run", r.id);
}}
>
<strong>{r.name}</strong>
<span>
{flowStatus[r.status]} · 第 {r.round} 轮
</span>
</button>
))}
<Pagination
size="small"
total={runTotal}
currentPage={runPage}
pageSize={25}
onPageChange={setRunPage}
/>
</>
) : (
<>
{assets.map((a) => (
<button
className={`research-library-item ${asset?.id === a.id ? "selected" : ""}`}
key={a.id}
onClick={() => void open(a)}
>
<strong>{a.name}</strong>
<span>
v{a.version} · {a.content.nodes.length} 个节点
</span>
</button>
))}
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={25}
onPageChange={setPage}
/>
</>
)}
</aside>
<div className="research-main">
{mode === "runs" ? (
run ? (
<>
<FlowCanvas
graph={run.definition}
types={types}
selected={selected}
onSelect={setSelected}
run={run}
/>
{selected && (
<div className="research-card">
<h4>所选节点产物</h4>
{run.steps
.filter((s) => s.node_id === selected)
.map((s) => (
<details key={s.id}>
<summary>
第 {s.round} 轮 · {flowStatus[s.status]}
</summary>
<pre className="code-block">
{JSON.stringify(s.output, null, 2)}
</pre>
</details>
))}
</div>
)}
<FlowRunView
run={run}
onRefresh={() => setRevision((v) => v + 1)}
onAction={onAction}
/>
</>
) : (
<p className="research-card">选择一条运行查看节点状态和产物。</p>
)
) : mode === "launch" && asset ? (
<>
<Button onClick={() => setMode("edit")}>返回流程</Button>
<FlowLaunchForm
workflow={asset}
onStarted={(r) => {
setRun(r);
setRunId(r.id);
localStorage.setItem("quantflow-selected-run", r.id);
setMode("runs");
}}
/>
</>
) : (
<>
<div className="research-card">
<div className="section-toolbar">
<Input
aria-label="流程名称"
value={graph.name}
onChange={(name) => change({ ...graph, name })}
/>
<div className="inline-actions">
{asset && (
<label>
历史版本
<select
aria-label="流程版本"
value={asset.version}
onChange={(e) =>
void open(asset, Number(e.target.value))
}
>
{versions.map((v) => (
<option key={v.version} value={v.version}>
v{v.version}
</option>
))}
</select>
</label>
)}
<Button
disabled={busy}
onClick={() =>
void validate().catch((e) => setError(e.message))
}
>
校验连接
</Button>
<Button disabled={busy} onClick={() => void save()}>
保存流程版本
</Button>
{asset && (
<Button disabled={busy} onClick={() => void save(true)}>
另存流程
</Button>
)}
<Button
theme="solid"
disabled={dirty || busy}
onClick={() => setMode("launch")}
>
启动已保存流程
</Button>
</div>
</div>
<p className="research-hint">
{valid
? "连接校验通过。"
: "保存时核验节点类型、连接和循环。"}{" "}
{dirty
? "有未保存草稿,启动前请保存版本。"
: `当前 v${asset?.version},运行固定此版本。`}{" "}
普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。
</p>
</div>
<div className="flow-editor">
<div>
<FlowCanvas
graph={graph}
types={types}
selected={selected}
onSelect={setSelected}
onChange={change}
/>
<div className="research-card">
<h4>连线</h4>
<div className="flow-connect">
<select
aria-label="连线起点"
value={source}
onChange={(e) => setSource(e.target.value)}
>
<option value="">选择上游</option>
{graph.nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
<span>→</span>
<select
aria-label="连线终点"
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">选择下游</option>
{graph.nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
<select
aria-label="连线条件"
value={branch}
disabled={
graph.nodes.find((n) => n.id === source)?.type !==
"condition"
}
onChange={(e) => setBranch(e.target.value)}
>
<option value="">始终</option>
{["pass", "review", "block"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
<Button onClick={addEdge}>添加连线</Button>
</div>
<div className="flow-edge-list">
{graph.edges.map((e, i) => (
<div key={i}>
<span>
{graph.nodes.find((n) => n.id === e.source)?.label}{" "}
→{" "}
{graph.nodes.find((n) => n.id === e.target)?.label}{" "}
{e.branch && `· ${e.branch}`}
</span>
<Button
size="small"
aria-label={`删除连线 ${i + 1}`}
onClick={() =>
change({
...graph,
edges: graph.edges.filter((_, j) => j !== i),
})
}
>
删除
</Button>
</div>
))}
</div>
</div>
</div>
<aside className="research-card flow-inspector">
<h4>原生节点</h4>
<select
aria-label="新增节点类型"
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
>
{types
.filter((t) => t.type !== "input")
.map((t) => (
<option key={t.type} value={t.type}>
{t.label}
</option>
))}
</select>
<Button disabled={graph.nodes.length >= 50} onClick={addNode}>
添加节点
</Button>
{node && (
<>
<h4>节点配置</h4>
<label>
显示名称
<Input
aria-label="节点名称"
value={node.label}
onChange={(label) => updateNode({ label })}
/>
</label>
<p className="research-hint">{node.id}</p>
{["generate", "feature"].includes(node.type) && (
<label>
研究说明
<TextArea
aria-label="节点研究说明"
value={String(node.config.prompt || "")}
onChange={(prompt) =>
updateNode({ config: { ...node.config, prompt } })
}
/>
</label>
)}
{["feature", "expand"].includes(node.type) && (
<>
<label>
固定素材版本
<select
aria-label="节点素材"
value={String(node.config.asset_id || "")}
onChange={(e) => {
const ref = refs.find(
(r) => r.id === e.target.value,
);
const {
asset_id: _,
version: __,
...rest
} = node.config;
updateNode({
config: ref
? {
...rest,
asset_id: ref.id,
version: ref.version,
}
: rest,
});
}}
>
<option value="">
{node.type === "feature"
? "由模型生成方案"
: "使用上游或启动模板"}
</option>
{refs
.filter(
(r) =>
r.kind ===
(node.type === "feature"
? "feature"
: "template"),
)
.map((r) => (
<option key={r.id} value={r.id}>
{r.name} · v{r.version}
</option>
))}
</select>
</label>
{Boolean(node.config.asset_id) && (
<label>
素材版本
<InputNumber
aria-label="节点素材版本"
min={1}
precision={0}
value={Number(node.config.version)}
onChange={(version) => {
if (typeof version === "number")
updateNode({
config: { ...node.config, version },
});
}}
/>
</label>
)}
</>
)}
{node.type === "variant" && (
<label>
生成方法
<select
aria-label="变体方法"
value={String(node.config.method || "structure")}
onChange={(e) =>
updateNode({ config: { method: e.target.value } })
}
>
<option value="structure">结构与字段变体</option>
<option value="settings">市场与设置变体</option>
</select>
</label>
)}
{node.type === "filter" && (
<fieldset>
<legend>保留评估结论</legend>
{["pass", "review", "block"].map((v) => (
<label key={v}>
<input
type="checkbox"
checked={(
(node.config.verdicts as string[]) || ["pass"]
).includes(v)}
onChange={(e) => {
const values = (node.config
.verdicts as string[]) || ["pass"];
updateNode({
config: {
verdicts: e.target.checked
? [...values, v]
: values.filter((x) => x !== v),
},
});
}}
/>
{v}
</label>
))}
</fieldset>
)}
{node.type === "iterate" && (
<label>
整个流程最大轮数
<InputNumber
aria-label="节点迭代轮数"
min={1}
max={100}
precision={0}
value={Number(node.config.max_rounds || 1)}
onChange={(max_rounds) => {
if (typeof max_rounds === "number")
updateNode({ config: { max_rounds } });
}}
/>
</label>
)}
{node.type === "condition" && (
<p>
根据上游规则评估的 pass / review / block
选择出边。模型建议不改变分支判断。
</p>
)}
{node.type !== "input" && (
<Button
type="danger"
onClick={() => {
change({
...graph,
nodes: graph.nodes.filter(
(n) => n.id !== node.id,
),
edges: graph.edges.filter(
(e) =>
e.source !== node.id && e.target !== node.id,
),
});
setSelected("input");
}}
>
删除节点
</Button>
)}
</>
)}
</aside>
</div>
</>
)}
</div>
</div>
</section>
);
}
+2 -1
View File
@@ -10,7 +10,8 @@ export function ResearchToolCard({ call, onAction }: ToolCardProps) {
</p> </p>
{typeof result.id === "string" && {typeof result.id === "string" &&
!result.report && !result.report &&
!result.authorization && ( !result.authorization &&
result.kind !== "workflow" && (
<Button <Button
onClick={() => onClick={() =>
onAction( onAction(
+1
View File
@@ -64,6 +64,7 @@ export type FlowRun = {
}; };
export const flowStatus: Record<string, string> = { export const flowStatus: Record<string, string> = {
queued: "等待推进", queued: "等待推进",
generated: "模型产物已保存",
running: "执行中", running: "执行中",
completed: "已完成", completed: "已完成",
skipped: "已跳过", skipped: "已跳过",
+146
View File
@@ -235,3 +235,149 @@
.research-saved-views .semi-select { .research-saved-views .semi-select {
min-width: 210px; min-width: 210px;
} }
/* Native research graph: same compact workspace surfaces and focus states. */
.flow-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) 235px;
gap: 12px;
align-items: start;
}
.flow-canvas-shell {
border: 1px solid var(--semi-color-border);
border-radius: 8px;
overflow: hidden;
background: var(--semi-color-bg-1);
margin-bottom: 12px;
}
.flow-canvas-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 12px;
color: var(--semi-color-text-2);
font-size: 12px;
}
.flow-canvas-viewport {
overflow: auto;
height: 460px;
background: radial-gradient(var(--semi-color-border) 1px, transparent 1px);
background-size: 20px 20px;
}
.flow-canvas {
position: relative;
}
.flow-edges {
position: absolute;
inset: 0;
pointer-events: none;
}
.flow-edges path {
fill: none;
stroke: var(--semi-color-text-2);
stroke-width: 1.6;
}
.flow-edges text {
fill: var(--semi-color-text-1);
font-size: 12px;
}
.flow-node {
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
width: 200px;
height: 100px;
padding: 12px;
border: 1px solid var(--semi-color-border);
border-radius: 8px;
background: var(--semi-color-bg-1);
color: var(--semi-color-text-0);
text-align: left;
cursor: grab;
box-shadow: 0 2px 8px #00000008;
}
.flow-node strong {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
}
.flow-node span {
font-size: 11px;
color: var(--semi-color-text-2);
}
.flow-node.selected,
.flow-node:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.flow-node.completed {
border-left: 4px solid var(--semi-color-success);
}
.flow-node.running,
.flow-node.waiting {
border-left: 4px solid var(--semi-color-primary);
}
.flow-node.blocked,
.flow-node.interrupted {
border-left: 4px solid var(--semi-color-warning);
}
.flow-node.skipped {
opacity: 0.55;
}
.flow-inspector {
display: grid;
gap: 12px;
}
.quantflow-workspace select {
border: 1px solid var(--semi-color-border);
border-radius: 4px;
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
padding: 7px 8px;
max-width: 100%;
}
.flow-inspector label {
display: grid;
gap: 6px;
}
.flow-inspector fieldset {
border: 1px solid var(--semi-color-border);
border-radius: 4px;
}
.flow-inspector fieldset label {
display: flex;
margin: 8px 0;
}
.flow-connect {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.flow-edge-list {
display: grid;
gap: 4px;
margin-top: 12px;
}
.flow-edge-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--semi-color-border);
padding: 4px 0;
}
.quantflow-workspace .section-toolbar > .semi-input-wrapper {
max-width: 240px;
}
@media (max-width: 1200px) {
.flow-editor {
grid-template-columns: minmax(0, 1fr);
}
.flow-inspector {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+236
View File
@@ -0,0 +1,236 @@
import { expect, test } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
const scope = {
instrument_type: "EQUITY",
region: "USA",
universe: "TOP3000",
delay: 1,
};
test("native QuantFlow canvas saves versions, branches and restores artifacts", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await page
.getByRole("navigation", { name: "主导航" })
.getByRole("button", { name: "QuantFlow", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "QuantFlow", 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");
const config = { base_url: "https://model.test/v1", model: "test-model" };
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...config, api_key: "synthetic-key" },
});
expect(
(
await (
await page.request.post("/api/v1/ai/settings/test", { headers })
).json()
).ready,
).toBe(true);
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...config, enabled: true },
});
for (const dataset_id of [null, "TEST_FIN"]) {
const job = await (
await page.request.post("/api/v1/catalog/sync-jobs", {
headers,
data: { scope, dataset_id },
})
).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(
(
await page.request.post("/api/v1/catalog/operators/refresh", { headers })
).ok(),
).toBe(true);
expect(
(
await page.request.post("/api/v1/catalog/setting-options/refresh", {
headers,
})
).ok(),
).toBe(true);
const template = await (
await page.request.post("/api/v1/research/assets", {
headers,
data: {
kind: "template",
content: {
name: "画布测试模板",
expression: "rank({field})",
variables: {
field: {
kind: "field",
field_type: "MATRIX",
values: ["TEST_FIN_001", "TEST_FIN_002"],
},
},
},
},
})
).json();
await page.reload();
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究");
async function add(kind: string, label: string) {
await page.getByLabel("新增节点类型").selectOption(kind);
await page.getByRole("button", { name: "添加节点", exact: true }).click();
await page.getByLabel("节点名称", { exact: true }).fill(label);
}
await add("expand", "模板展开");
await page.getByLabel("节点素材", { exact: true }).selectOption(template.id);
await add("backtest", "模拟比较");
await add("evaluate", "规则评估");
await add("condition", "分支选择");
await add("summarize", "通过汇总");
await add("summarize", "待核实汇总");
await add("summarize", "失败汇总");
async function connect(source: string, target: string, branch?: string) {
await page.getByLabel("连线起点").selectOption({ label: source });
await page.getByLabel("连线终点").selectOption({ label: target });
if (branch) await page.getByLabel("连线条件").selectOption(branch);
await page.getByRole("button", { name: "添加连线", exact: true }).click();
}
await connect("固定输入", "模板展开");
await connect("模板展开", "模拟比较");
await connect("模拟比较", "规则评估");
await connect("规则评估", "分支选择");
await connect("分支选择", "通过汇总", "pass");
await connect("分支选择", "待核实汇总", "review");
await connect("分支选择", "失败汇总", "block");
// Native node movement remains keyboard accessible and is saved in the version.
const input = page.getByRole("button", {
name: "节点 固定输入",
exact: true,
});
await input.focus();
await input.press("ArrowRight");
await page.getByRole("button", { name: "校验连接", exact: true }).click();
await expect(
page.getByText("连接校验通过。", { exact: false }),
).toBeVisible();
const savedResponse = page.waitForResponse(
(r) =>
r.url().endsWith("/research/assets") && r.request().method() === "POST",
);
await page.getByRole("button", { name: "保存流程版本", exact: true }).click();
const saved = await (await savedResponse).json();
expect(saved.content.nodes[0].x).toBe(50);
await page.getByLabel("流程名称", { exact: true }).fill("画布原生研究 v2");
await expect(
page.getByRole("button", { name: "启动已保存流程", exact: true }),
).toBeDisabled();
await page.getByRole("button", { name: "保存流程版本", exact: true }).click();
await expect(page.getByLabel("流程版本", { exact: true })).toHaveValue("2");
await page.getByRole("button", { name: "适应画布", exact: true }).click();
await page.screenshot({
path: "/tmp/wq-stage-four-canvas.png",
fullPage: true,
});
await page
.getByRole("button", { name: "启动已保存流程", exact: true })
.click();
await page.getByLabel("自动研究假设").fill("画布条件分支与固定版本");
await page.getByRole("combobox", { name: "自动研究固定输入" }).click();
await page
.getByRole("option")
.filter({ hasText: fixed.id.slice(0, 8) })
.click();
await page.getByRole("heading", { name: "QuantFlow", exact: true }).click();
for (const [label, value] of [
["最大轮数", "1"],
["最大模拟条目数", "2"],
["最大模型调用数", "1"],
["每轮候选上限", "2"],
])
await page.getByLabel(label, { exact: true }).fill(value);
await page
.getByRole("button", { name: "核对并启动研究", exact: true })
.click();
const dialog = page.getByRole("dialog", { name: "确认自动研究授权" });
await expect(dialog).toContainText("流程:画布原生研究 v2 · v2");
const started = page.waitForResponse(
(r) =>
r.url().endsWith("/research/flows/runs") &&
r.request().method() === "POST",
);
await dialog.getByRole("button", { name: "confirm", exact: true }).click();
const response = await started;
expect(response.status()).toBe(201);
const run = await response.json();
await expect
.poll(
async () =>
(
await (
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
).json()
).status,
{ timeout: 40000 },
)
.toBe("completed");
const completed = await (
await page.request.get(`/api/v1/research/flows/runs/${run.id}`)
).json();
expect(completed.model_calls_used).toBe(0);
expect(completed.simulations_used).toBe(2);
expect(
completed.steps.filter((s: { status: string }) => s.status === "skipped"),
).toHaveLength(2);
expect(completed.authorization.workflow.version).toBe(2);
await page.reload();
await expect(
page.getByRole("region", { name: "研究运行详情" }),
).toContainText("画布原生研究 v2");
await page
.getByRole("button", { name: "节点 规则评估", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "所选节点产物" }),
).toBeVisible();
await page.screenshot({ path: "/tmp/wq-stage-four-run.png", fullPage: true });
expect(errors).toEqual([]);
});