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
+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