Files
worldquant-alpha-system/backend/tests/test_backtests.py
T

414 lines
18 KiB
Python

"""End-to-end business tests: real persistence/runtime, only the platform HTTP is replaced."""
import asyncio
import httpx
import pytest
from sqlalchemy import func, select
from app.backtests.contracts import SimulationSettings
from app.models import Account, Alpha, BacktestResult, BacktestRun, Research, SimulationAttempt
from app.security import cipher
from app.worldquant import WqClient
from tests.backtest_fake import Platform
PREFIX = "/api/v1/backtests"
PARAMS = SimulationSettings(region="USA", universe="TOP3000", delay=1).model_dump()
def candidate(index=0, **settings):
return {
"client_item_id": f"item-{index}",
"expression": f"rank(close) + {index}",
"settings": PARAMS | settings,
}
async def setup(app):
platform = Platform()
runner = app.state.runner
await runner.client.close()
runner.client = WqClient(app.state.settings, transport=httpx.MockTransport(platform))
runner.backtests.client = runner.client
runner.backtests.poll_interval = 0
async with app.state.sessions.begin() as db:
account = await db.get(Account, 1)
account.email, account.wq_user_id, account.connection_status = (
"synthetic@example.com",
"TEST_USER",
"connected",
)
account.password_encrypted = cipher(app.state.settings).encrypt(platform.secret.encode()).decode()
return platform, runner.backtests
async def preview(client, candidates=None):
response = await client.post(
f"{PREFIX}/previews",
json={
"inline": {
"name": "测试研究",
"source": {"kind": "test"},
"candidates": candidates or [candidate()],
}
},
)
assert response.status_code == 201, response.text
return response.json()
async def start(client, p, key="request-1"):
response = await client.post(
f"{PREFIX}/runs",
json={"preview_id": p["preview_id"], "version": p["version"], "idempotency_key": key},
)
assert response.status_code == 202, response.text
return response.json()
async def execute(app, lane, run_id):
async with app.state.sessions() as db:
ids = list(
await db.scalars(
select(SimulationAttempt.id)
.where(SimulationAttempt.run_id == run_id)
.order_by(SimulationAttempt.ordinal)
)
)
for aid in ids:
await lane.step(aid)
await lane.step(aid)
return ids
async def test_fixed_preview_grouping_mapping_and_history(app, logged_in):
platform, lane = await setup(app)
p = await preview(logged_in, [candidate(0), candidate(1, universe="TOP1000"), candidate(2, delay=0)])
assert p["batch_count"] == 2 and p["total"] == 3
run = await start(logged_in, p)
again = await start(logged_in, p)
assert run["backtest_run_id"] == again["backtest_run_id"]
rid = run["backtest_run_id"]
await execute(app, lane, rid)
data = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()
assert len(platform.posts) == 2
assert all(i["persistence_status"] == "saved" for i in data["items"]), data
for item in data["items"]:
assert item["result"]["snapshot"]["regular"]["code"] == item["expression"]
assert item["result"]["snapshot"]["settings"] == item["settings"]
assert item["result"]["snapshot"]["is"]["sharpe"] is None
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
item = data["items"][0]
async with app.state.sessions.begin() as db:
alpha = await db.get(Alpha, item["alpha_id"])
alpha.is_metrics = {"sharpe": 999}
assert await db.get(Research, alpha.id)
historical = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()
assert historical["items"][0]["result"]["snapshot"]["is"]["sharpe"] is None
events = (await logged_in.get(f"{PREFIX}/runs/{rid}/events?limit=2")).json()
later = (await logged_in.get(f"{PREFIX}/runs/{rid}/events?after={events['next_cursor']}")).json()
assert events["has_more"] and later["items"][0]["seq"] > events["next_cursor"]
assert (await preview(logged_in))["duplicate_count"] == 1
@pytest.mark.parametrize("rejection", ["unknown", "missing_location"])
async def test_unknown_submission_never_reposted(app, logged_in, rejection):
platform, lane = await setup(app)
platform.reject = rejection
run = await start(logged_in, await preview(logged_in))
rid = run["backtest_run_id"]
ids = await execute(app, lane, rid)
# A process crash/recovery must not turn an unknown POST into queued work.
await lane.start()
await lane.stop()
response = await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "recover", "version": 1})
assert response.json()["status"] == "needs_review"
async with app.state.sessions() as db:
assert (await db.get(SimulationAttempt, ids[0])).state == "needs_review"
assert len(platform.posts) == 1
async def test_partial_failure_and_rerun_only_selected(app, logged_in):
platform, lane = await setup(app)
platform.fail_child = 0
run = await start(logged_in, await preview(logged_in, [candidate(0), candidate(1)]))
rid = run["backtest_run_id"]
await execute(app, lane, rid)
result = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()["items"]
assert result[0]["platform_status"] == "failed" and result[1]["persistence_status"] == "saved"
rerun = await logged_in.post(f"{PREFIX}/runs/{rid}/rerun-preview", json={"item_ids": [result[0]["id"]]})
assert rerun.status_code == 201
assert rerun.json()["total"] == 1 and rerun.json()["source"]["parent_run_id"] == rid
assert len(platform.posts) == 1
async def test_detail_failure_recovers_without_resubmit(app, logged_in):
platform, lane = await setup(app)
platform.detail_fail = True
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
ids = await execute(app, lane, rid)
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "needs_review"
platform.detail_fail = False
await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "recover", "version": 1})
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 1
async def test_draft_version_snapshot_and_pause_stop(app, logged_in):
platform, lane = await setup(app)
body = {"name": "草稿", "candidates": [candidate(0), candidate(1, delay=0)]}
d = (await logged_in.post(f"{PREFIX}/drafts", json=body)).json()
p = (await logged_in.post(f"{PREFIX}/previews", json={"draft_id": d["id"], "draft_version": 1})).json()
changed = await logged_in.put(
f"{PREFIX}/drafts/{d['id']}", json=body | {"version": 1, "candidates": [candidate(9)]}
)
assert changed.json()["version"] == 2
assert (
await logged_in.post(f"{PREFIX}/previews", json={"draft_id": d["id"], "draft_version": 1})
).status_code == 409
run = await start(logged_in, p)
rid = run["backtest_run_id"]
async with app.state.sessions() as db:
ids = list(
await db.scalars(
select(SimulationAttempt.id)
.where(SimulationAttempt.run_id == rid)
.order_by(SimulationAttempt.ordinal)
)
)
await lane.step(ids[0])
await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "pause", "version": 1})
await lane.step(ids[1])
await lane.step(ids[0])
assert len(platform.posts) == 1
await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "stop", "version": 2})
r = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()["items"]
assert r[0]["persistence_status"] == "saved" and r[1]["platform_status"] == "skipped"
assert r[0]["expression"] == candidate(0)["expression"]
async def test_batch_missing_child_does_not_misattribute(app, logged_in):
platform, lane = await setup(app)
platform.missing = True
rid = (await start(logged_in, await preview(logged_in, [candidate(0), candidate(1)])))["backtest_run_id"]
ids = await execute(app, lane, rid)
items = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()["items"]
assert items[0]["platform_status"] == "unknown"
assert items[1]["persistence_status"] == "saved"
platform.simulations["p1"]["children"] = ["p1c1", "p1c0"]
await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "recover", "version": 1})
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 1
async def test_validation_auth_and_idempotency_conflict(app, logged_in, client):
await setup(app)
assert (
await logged_in.post(
f"{PREFIX}/previews",
json={"inline": {"name": "x", "candidates": [candidate() | {"alpha_type": "SUPER"}]}},
)
).status_code == 422
p1, p2 = await preview(logged_in), await preview(logged_in, [candidate(2)])
await start(logged_in, p1)
assert (
await logged_in.post(
f"{PREFIX}/runs", json={"preview_id": p2["preview_id"], "idempotency_key": "request-1"}
)
).status_code == 409
assert (await logged_in.get(f"{PREFIX}/runs?limit=101")).status_code == 422
await client.post("/api/v1/auth/logout")
assert (await client.get(f"{PREFIX}/runs")).status_code == 401
async def tick(lane):
await lane.tick()
await asyncio.gather(*lane.tasks.values(), return_exceptions=False)
async def test_account_budget_round_robin_and_sync_independence(app, logged_in):
platform, lane = await setup(app)
assert (
await logged_in.put(f"{PREFIX}/config", json={"concurrency": 1, "batch_size": 1, "version": 1})
).status_code == 200
r1 = await start(logged_in, await preview(logged_in, [candidate(0), candidate(1)]), "first")
r2 = await start(logged_in, await preview(logged_in, [candidate(2), candidate(3)]), "second")
await tick(lane) # one submission, occupied until remote terminal
assert len(platform.posts) == 1
await tick(lane) # poll first result
await tick(lane) # other run gets next slot
assert len(platform.posts) == 2
assert platform.posts[0][0]["regular"] == candidate(0)["expression"]
assert platform.posts[1][0]["regular"] == candidate(2)["expression"]
platform.pending = True
sync = await logged_in.post("/api/v1/sync-jobs", json={"kind": "full_sync"})
await app.state.runner.run_next()
assert (await logged_in.get(f"/api/v1/sync-jobs/{sync.json()['id']}")).json()["status"] == "completed"
await logged_in.put(f"{PREFIX}/config", json={"concurrency": 2, "batch_size": 8, "version": 2})
await tick(lane)
assert len(platform.posts) == 3
# Batch sizing of both existing runs remains 1 despite config update.
assert all(len(p) == 1 for p in platform.posts)
await logged_in.put(f"{PREFIX}/config", json={"concurrency": 1, "batch_size": 8, "version": 3})
await tick(lane)
assert len(platform.posts) == 3
await lane.interrupt()
assert r1["batch_size"] == r2["batch_size"] == 1
async def test_rate_limit_and_failed_submit_are_bounded(app, logged_in):
platform, lane = await setup(app)
platform.reject = "rate"
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
async with app.state.sessions() as db:
aid = await db.scalar(select(SimulationAttempt.id).where(SimulationAttempt.run_id == rid))
for _ in range(app.state.settings.retry_attempts):
await lane.step(aid)
await asyncio.sleep(0.02)
assert len(platform.posts) == app.state.settings.retry_attempts
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed_with_errors"
assert platform.secret not in (await logged_in.get(f"{PREFIX}/runs/{rid}/attempts")).text
async def test_poll_timeout_and_crash_after_acceptance(app, logged_in):
platform, lane = await setup(app)
lane.poll_limit = 1
platform.pending = True
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
ids = await execute(app, lane, rid)
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "needs_review"
platform.pending = False
await logged_in.post(f"{PREFIX}/runs/{rid}/control", json={"action": "recover", "version": 1})
# Simulate a crash checkpoint with the Location already persisted.
async with app.state.sessions.begin() as db:
a = await db.get(SimulationAttempt, ids[0])
a.state = "submitting"
await lane.start()
await lane.stop()
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 1
async def test_result_transaction_failure_recovers_from_saved_receipt(app, logged_in):
from sqlalchemy import event
from sqlalchemy.exc import OperationalError
platform, lane = await setup(app)
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
failed = False
def fail_once(conn, cursor, statement, parameters, context, executemany):
nonlocal failed
if "INSERT INTO backtest_results" in statement and not failed:
failed = True
raise OperationalError("synthetic persistence outage", {}, Exception("synthetic"))
event.listen(app.state.engine.sync_engine, "before_cursor_execute", fail_once)
try:
ids = await execute(app, lane, rid)
finally:
event.remove(app.state.engine.sync_engine, "before_cursor_execute", fail_once)
assert failed
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(BacktestResult)) == 0
assert await db.scalar(select(func.count()).select_from(Alpha)) == 0
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 1
async def test_ai_fixed_set_confirmation_and_duplicate_decision(app, logged_in):
from tests.test_ai import configure
from tests.test_ai import start as start_ai
platform, lane = await setup(app)
await configure(app, logged_in)
_, run, _ = await start_ai(app, logged_in, "回测固定候选")
assert run["status"] == "waiting_approval", run
approval = next(c for c in run["tools"] if c["name"] == "start_backtest")
assert approval["preview"]["backtest"]["total"] == 1
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(BacktestRun)) == 0
for _ in range(2):
response = await logged_in.post(
f"/api/v1/ai/approvals/{approval['id']}/decision", json={"approved": True}
)
assert response.status_code == 200, response.text
async with app.state.sessions() as db:
rows = list(await db.scalars(select(BacktestRun)))
assert len(rows) == 1
assert rows[0].ai_context["ai_run_id"] == run["id"]
await logged_in.post(f"/api/v1/ai/runs/{run['id']}/cancel")
await execute(app, lane, rows[0].id)
assert len(platform.posts) == 1
assert (await logged_in.get(f"{PREFIX}/runs/{rows[0].id}")).json()["status"] == "completed"
async def test_duplicate_inputs_are_separate_attempts_and_share_alpha_safely(app, logged_in):
platform, lane = await setup(app)
platform.existing_alpha_ids = ["shared_alpha"]
p = await preview(logged_in, [candidate(0), candidate(0) | {"client_item_id": "other-experiment"}])
assert p["batch_count"] == 2 and p["duplicate_count"] == 1
rid = (await start(logged_in, p))["backtest_run_id"]
await execute(app, lane, rid)
async with app.state.sessions() as db:
assert await db.scalar(select(func.count()).select_from(Alpha)) == 1
assert await db.scalar(select(func.count()).select_from(BacktestResult)) == 2
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
async def test_original_reference_recovery_without_new_post(app, logged_in):
platform, lane = await setup(app)
platform.reject = "missing_location"
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
ids = await execute(app, lane, rid)
path = f"{PREFIX}/attempts/{ids[0]}/reference"
assert (
await logged_in.post(
path, json={"progress_url": "https://foreign.example/simulations/p1", "version": 1}
)
).status_code == 422
linked = await logged_in.post(path, json={"progress_url": "/simulations/p1", "version": 1})
assert linked.status_code == 200, linked.text
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 1
async def test_preview_subset_uses_whole_snapshot_and_does_not_change_original(app, logged_in):
await setup(app)
p = await preview(logged_in, [candidate(i) for i in range(40)])
subset = await logged_in.post(
f"{PREFIX}/previews/{p['preview_id']}/subset", json={"exclude_ids": ["item-30"]}
)
assert subset.json()["total"] == 39 and subset.json()["preview_id"] != p["preview_id"]
assert (await logged_in.get(f"{PREFIX}/previews/{p['preview_id']}")).json()["total"] == 40
async def test_session_reauthentication_does_not_retry_accepted_submission(app, logged_in):
platform, lane = await setup(app)
platform.reject = "session"
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
ids = await execute(app, lane, rid)
await lane.step(ids[0])
assert (await logged_in.get(f"{PREFIX}/runs/{rid}")).json()["status"] == "completed"
assert len(platform.posts) == 2 # first explicitly rejected with 401, second accepted
async def test_terminal_detail_failure_releases_slot_but_keeps_platform_success(app, logged_in):
platform, lane = await setup(app)
await logged_in.put(f"{PREFIX}/config", json={"concurrency": 1, "batch_size": 1, "version": 1})
platform.detail_fail = True
rid = (await start(logged_in, await preview(logged_in)))["backtest_run_id"]
await execute(app, lane, rid)
item = (await logged_in.get(f"{PREFIX}/runs/{rid}/results")).json()["items"][0]
assert item["platform_status"] == "completed" and item["collection_status"] == "failed"
await start(logged_in, await preview(logged_in, [candidate(2)]), "next")
await tick(lane)
assert len(platform.posts) == 2
await lane.interrupt()