287 lines
13 KiB
Python
287 lines
13 KiB
Python
|
|
"""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
|