merge: integrate backtests with main dataset catalog and sequence migration 0004

This commit is contained in:
yuxuanhui
2026-09-08 10:09:37 +08:00
34 changed files with 3041 additions and 56 deletions
+4
View File
@@ -13,6 +13,7 @@ from app.models import Base
from app.worldquant import WqClient
from tests.ai_fake import fake_model
from tests.backtest_fake import Platform
from tests.catalog_fake import catalog_response
TEST_PASSWORD = "browser-test-password"
@@ -111,6 +112,9 @@ def create_test_app():
return simulations(request)
if request.method != "GET":
raise AssertionError("Browser acceptance attempted an upstream mutation")
catalog = catalog_response(request)
if catalog is not None:
return catalog
if path == "/users/self":
return httpx.Response(
200,
+55
View File
@@ -0,0 +1,55 @@
"""Synthetic HTTP catalog, including page overlap and unknown metrics."""
import httpx
def field_records(dataset="TEST_FIN", count=123):
return [
dict(
id=f"{dataset}_{i:03}",
name=f"TEST 字段 {i:03}",
dataset={"id": dataset},
type="FUTURE_TYPE" if i == 122 else "VECTOR" if i % 3 == 0 else "MATRIX",
coverage=None if i == 122 else 0.95 if i % 2 else 0.6,
userCount=None if i == 122 else i,
alphaCount=i * 2,
description=None if i == 122 else f"合成字段说明 {i}",
)
for i in range(count)
]
def catalog_response(request, fields=None):
path, params = request.url.path, request.url.params
if path not in ("/data-sets", "/data-fields"):
return None
assert request.method == "GET"
assert params["instrumentType"] == "EQUITY"
assert params["region"] and params["universe"] and params["delay"] in ("0", "1")
dataset = params.get("dataset.id", "TEST_FIN")
rows = (
[
{
"id": "TEST_FIN",
"name": "TEST 财务报表",
"category": {"name": "基本面"},
"subcategory": {"name": "财务报表"},
"fieldCount": 123,
"description": "合成数据,仅用于验收",
},
{
"id": "TEST_NEWS",
"name": "TEST 新闻",
"category": {"name": "新闻"},
"subcategory": {"name": "情绪"},
"fieldCount": 3,
},
{"id": "TEST_UNKNOWN", "name": "TEST 未分类", "fieldCount": 0},
]
if path == "/data-sets"
else (fields if fields is not None else field_records(dataset, 123 if dataset == "TEST_FIN" else 3))
)
if path == "/data-fields" and len(rows) > 50:
rows = rows[:50] + [rows[49]] + rows[50:]
offset, limit = int(params.get("offset", 0)), int(params.get("limit", 50))
return httpx.Response(200, json={"results": rows[offset : offset + limit]})
+124
View File
@@ -0,0 +1,124 @@
"""One-off acceptance against the dedicated local PostgreSQL catalog_test database."""
import asyncio
import os
import re
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
database_name = os.environ.get("WQ_CATALOG_ACCEPTANCE_DATABASE", "catalog_flow_test")
if not re.fullmatch(r"catalog_[a-z0-9_]{1,40}", database_name):
raise ValueError("Acceptance requires a dedicated catalog_* database")
URL = f"postgresql+asyncpg://postgres:catalog-test-only@127.0.0.1:18436/{database_name}"
os.environ.update(
DATABASE_URL=URL, ADMIN_PASSWORD="migration-test-only", ENCRYPTION_KEY=Fernet.generate_key().decode()
)
async def sql(statement):
engine = create_async_engine(URL)
async with engine.begin() as connection:
result = await connection.execute(text(statement))
value = result.fetchall() if result.returns_rows else None
await engine.dispose()
return value
if __name__ == "__main__":
config = Config("alembic.ini")
if asyncio.run(sql("SELECT tablename FROM pg_tables WHERE schemaname='public'")):
raise RuntimeError("Acceptance database must be empty; existing data will not be overwritten")
command.upgrade(config, "0002")
asyncio.run(
sql(
"INSERT INTO alphas (id, hidden, settings, is_metrics, os_metrics, checks, synced_at, raw) VALUES ('MIGRATION_TEST', false, '{}', '{}', '{}', '[]', now(), '{}');"
)
)
asyncio.run(
sql(
"INSERT INTO research (alpha_id, note, tags, favorite, state, updated_at, version) VALUES ('MIGRATION_TEST', 'preserve research', '[]', false, 'inbox', now(), 7);"
)
)
command.upgrade(config, "head")
command.check(config)
assert asyncio.run(sql("SELECT note, version FROM research WHERE alpha_id='MIGRATION_TEST'")) == [
("preserve research", 7)
]
assert asyncio.run(sql("SELECT count(*) FROM catalog_batches")) == [(0,)]
command.downgrade(config, "0002")
command.upgrade(config, "head")
command.check(config)
assert asyncio.run(sql("SELECT note, version FROM research WHERE alpha_id='MIGRATION_TEST'")) == [
("preserve research", 7)
]
print(
"PostgreSQL 17: 0002 → 0003, downgrade/re-upgrade, metadata check, Alpha/research preservation passed"
)
async def flow():
import httpx
from app.config import Settings
from app.main import create_app
from app.worldquant import WqClient
from tests.catalog_fake import catalog_response
from tests.test_catalog import SCOPE, prepare, search, sync
def upstream(request):
if request.url.path == "/authentication":
return httpx.Response(201, json={"token": {"expiry": 14400}})
if request.url.path == "/users/self":
return httpx.Response(200, json={"id": "PG_TEST_USER"})
assert request.method == "GET"
return catalog_response(request) or httpx.Response(404)
settings = Settings(_env_file=None, enable_runner=False, public_origin="http://testserver")
app = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream)))
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
headers={"X-WQ-Request": "1"},
) as client:
assert (
await client.post(
"/api/v1/auth/login", json={"username": "admin", "password": "migration-test-only"}
)
).status_code == 200
await client.put(
"/api/v1/account/credentials",
json={"email": "pg@example.com", "password": "synthetic-only"},
)
job = (await client.post("/api/v1/account/connect")).json()
await app.state.runner.execute(job["id"])
catalog = (client, app.state.runner, {})
assert (await sync(catalog))["status"] == "completed"
version = (await sync(catalog, "TEST_FIN"))["id"]
result = await search(client, "/datasets/TEST_FIN/fields")
assert result["complete_count"] == 123
draft = (await prepare(client, version)).json()
assert len(draft["field_ids"]) == 123
responses = await asyncio.gather(
*[
client.patch(
"/api/v1/catalog/datasets/TEST_FIN/research",
params=SCOPE,
json={"version": 1, "note": value},
)
for value in ["one", "two"]
]
)
assert sorted(r.status_code for r in responses) == [200, 409]
await sync(catalog, "TEST_FIN")
assert (await prepare(client, version)).status_code == 409
persisted = (await client.get("/api/v1/catalog/inputs/" + draft["id"])).json()
assert persisted == draft
print(
"PostgreSQL: real API/runner multi-page dedupe, immutable draft, refresh conflict and concurrent note CAS passed"
)
asyncio.run(flow())
+257
View File
@@ -0,0 +1,257 @@
"""Public API through real business/runner/database; only upstream HTTP is replaced."""
import asyncio
import httpx
import pytest
from app.jobs import Runner
from app.models import Job
from app.worldquant import WqClient
from tests.catalog_fake import catalog_response, field_records
SCOPE = dict(instrument_type="EQUITY", region="USA", universe="TOP3000", delay=1)
BASE = "/api/v1/catalog"
@pytest.fixture
async def catalog(logged_in, app):
state = {"fail": False, "fields": field_records(), "calls": [], "mode": "", "block": None}
async def upstream(request):
state["calls"].append((request.url.path, int(request.url.params.get("offset", 0))))
if request.url.path == "/authentication":
if state.get("persona"):
return httpx.Response(
401, headers={"WWW-Authenticate": "persona", "Location": "/authentication/persona/test"}
)
return httpx.Response(201, json={"token": {"expiry": 14400}})
assert request.method == "GET"
if request.url.path == "/users/self":
return httpx.Response(200, json={"id": "TEST_USER"})
if request.url.path == "/data-fields":
if state.get("throttle"):
state["throttle"] = False
return httpx.Response(429, headers={"Retry-After": "2"})
if state["mode"] == "invalid-next":
return httpx.Response(200, json={"results": state["fields"][:50], "next": []})
if state["mode"] == "missing-owner":
return httpx.Response(200, json={"results": [{"id": "UNOWNED"}], "next": None})
if state["mode"] == "coverage-unit":
return httpx.Response(
200, json={"results": [{**state["fields"][0], "coverage": 95}], "next": None}
)
if int(request.url.params["offset"]) >= 50:
if state["block"]:
state["block"].set()
await asyncio.Future()
if state["fail"]:
return httpx.Response(403)
if state["mode"] == "early":
return httpx.Response(200, json={"results": [], "next": "/next", "count": 123})
if state["mode"] == "repeat":
return httpx.Response(200, json={"results": state["fields"][:50], "next": "/next"})
if state["mode"] == "wrong-owner":
return httpx.Response(200, json={"results": field_records("OTHER", 1)})
return catalog_response(request, state["fields"]) or httpx.Response(404)
await app.state.runner.client.close()
app.state.runner.client = WqClient(app.state.settings, transport=httpx.MockTransport(upstream))
client = logged_in
assert (
await client.put(
"/api/v1/account/credentials", json={"email": "test@example.com", "password": "test-only"}
)
).status_code == 200
connect = (await client.post("/api/v1/account/connect")).json()
await app.state.runner.execute(connect["id"])
return client, app.state.runner, state
async def sync(catalog, dataset=None, scope=SCOPE):
client, runner, _ = catalog
response = await client.post(BASE + "/sync-jobs", json={"scope": scope, "dataset_id": dataset})
assert response.status_code == 202, response.text
job = response.json()
await runner.execute(job["id"])
return (await client.get("/api/v1/sync-jobs/" + job["id"])).json()
async def search(client, suffix="/datasets", **params):
response = await client.get(BASE + suffix, params={**SCOPE, **params})
assert response.status_code == 200, response.text
return response.json()
async def prepare(client, version, **changes):
return await client.post(
BASE + "/inputs",
json={
"scope": SCOPE,
"dataset_id": "TEST_FIN",
"collection_version": version,
"selection": "all",
**changes,
},
)
async def test_complete_workflow_filters_notes_immutable_input(catalog):
client, _, state = catalog
assert (await search(client))["total"] == 0
assert (await sync(catalog))["status"] == "completed"
datasets = await search(client, category="基本面", subcategory="财务报表")
assert [r["id"] for r in datasets["items"]] == ["TEST_FIN"]
assert datasets["items"][0]["complete_count"] is None
assert (await sync(catalog, "TEST_FIN"))["processed"] == 123
fields = await search(client, "/datasets/TEST_FIN/fields", q="字段 12", limit=1)
assert fields["total"] == 3 and fields["complete_count"] == 123 and len(fields["items"]) == 1
version = fields["collection_version"]
response = await prepare(client, version)
assert response.status_code == 201, response.text
draft = response.json()
assert len(draft["field_ids"]) == 123 and draft["status"] == "draft"
for suffix in ["/datasets/TEST_FIN", "/datasets/TEST_FIN/fields/TEST_FIN_122"]:
detail = await search(client, suffix)
assert detail["research"]["version"] == 1
response = await client.patch(
BASE + suffix + "/research", params=SCOPE, json={"version": 1, "note": "保留研究备注"}
)
assert response.status_code == 200
assert (
await client.patch(
BASE + suffix + "/research", params=SCOPE, json={"version": 1, "note": "不能覆盖"}
)
).status_code == 409
detail = await search(client, "/datasets/TEST_FIN/fields/TEST_FIN_122")
assert detail["coverage"] is None and detail["unit"] is None and detail["field_type"] == "FUTURE_TYPE"
assert (await search(client, "/datasets/TEST_FIN/fields", coverage_min=0))["total"] == 122
state["fields"] = field_records(count=125)
assert (await sync(catalog, "TEST_FIN"))["processed"] == 125
assert (await sync(catalog))["status"] == "completed"
newer = await search(client, "/datasets/TEST_FIN/fields")
assert newer["collection_version"] != version
assert (await prepare(client, version)).status_code == 409
assert len((await prepare(client, newer["collection_version"])).json()["field_ids"]) == 125
assert (await client.get(BASE + "/inputs/" + draft["id"])).json() == draft
assert (await search(client, "/datasets/TEST_FIN/fields/TEST_FIN_122"))["research"][
"note"
] == "保留研究备注"
assert (await search(client, "/datasets/TEST_FIN"))["research"]["note"] == "保留研究备注"
async def test_partial_refresh_resume_cancel_restart_keeps_old_version(catalog):
client, runner, state = catalog
await sync(catalog)
state["fail"] = True
job = await sync(catalog, "TEST_FIN")
assert job["status"] == "failed" and job["processed"] == 50
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] is None
assert (await prepare(client, job["id"])).status_code == 409
state["fail"] = False
state["calls"].clear()
assert (await client.post("/api/v1/sync-jobs/" + job["id"] + "/retry")).status_code == 200
await runner.execute(job["id"])
assert state["calls"][0] == ("/data-fields", 50)
old_version = (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"]
state["fail"] = True
refresh = await sync(catalog, "TEST_FIN")
assert refresh["status"] == "failed"
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] == old_version
state["fail"] = False
state["calls"].clear()
async with runner.sessions() as db:
row = await db.get(Job, refresh["id"])
row.status = "running"
await db.commit()
restarted = Runner(runner.sessions, runner.settings, runner.client)
await restarted.start()
async with asyncio.timeout(5):
while True:
response = (await client.get("/api/v1/sync-jobs/" + refresh["id"])).json()
if response["status"] in ("completed", "failed"):
break
await asyncio.sleep(0.02)
assert response["status"] == "completed"
assert state["calls"][0] == ("/data-fields", 50)
state["block"] = asyncio.Event()
response = await client.post(BASE + "/sync-jobs", json={"scope": SCOPE, "dataset_id": "TEST_FIN"})
cancel_id = response.json()["id"]
restarted.wake.set()
await asyncio.wait_for(state["block"].wait(), 5)
await client.post("/api/v1/sync-jobs/" + cancel_id + "/cancel")
await restarted.cancel(cancel_id)
assert (await client.get("/api/v1/sync-jobs/" + cancel_id)).json()["status"] == "cancelled"
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] == refresh["id"]
await restarted.stop()
@pytest.mark.parametrize(
"mode", ["early", "repeat", "wrong-owner", "invalid-next", "missing-owner", "coverage-unit"]
)
async def test_anomalous_pagination_is_never_complete(catalog, mode):
client, _, state = catalog
await sync(catalog)
state["mode"] = mode
assert (await sync(catalog, "TEST_FIN"))["status"] == "failed"
assert (await search(client, "/datasets/TEST_FIN/fields"))["collection_version"] is None
async def test_scope_ownership_empty_and_unknown_fields_are_rejected(catalog):
client, _, _ = catalog
await sync(catalog)
version = (await sync(catalog, "TEST_FIN"))["id"]
assert (await prepare(client, version, selection="explicit", excluded_ids=["OTHER"])).status_code == 422
assert (
await prepare(
client, version, selection="explicit", excluded_ids=[f"TEST_FIN_{i:03}" for i in range(123)]
)
).status_code == 422
assert (await prepare(client, version, dataset_id="TEST_NEWS")).status_code == 409
assert (await prepare(client, version, scope={**SCOPE, "delay": 0})).status_code == 404
assert (await prepare(client, version, scope={**SCOPE, "region": "CHN"})).status_code == 422
subset = await prepare(client, version, selection="explicit", excluded_ids=["TEST_FIN_110"])
assert subset.status_code == 201 and len(subset.json()["field_ids"]) == 122
assert "TEST_FIN_110" not in subset.json()["field_ids"]
other = {**SCOPE, "delay": 0}
await sync(catalog, scope=other)
await sync(catalog, "TEST_FIN", scope=other)
assert (await prepare(client, version, scope=other)).status_code == 409
assert len((await client.get(BASE + "/inputs", params=SCOPE)).json()) == 1
async def test_catalog_authentication_and_origin(app, client):
assert (await client.get(BASE + "/datasets", params=SCOPE)).status_code == 401
assert (
await client.post(BASE + "/sync-jobs", headers={"Origin": "http://evil.test"}, json={"scope": SCOPE})
).status_code == 403
async def test_retry_after_auth_wait_disconnect_and_collection_manifest(catalog):
client, runner, state = catalog
await sync(catalog)
delays = []
async def sleep(delay):
delays.append(delay)
runner.client.sleep = sleep
state["throttle"] = True
job = await sync(catalog, "TEST_FIN")
assert job["status"] == "completed" and delays == [2]
manifest = await search(client, "/datasets/TEST_FIN/collection")
assert manifest["collection_version"] == job["id"] and len(manifest["field_ids"]) == 123
state["persona"] = True
runner.client.authenticated = False
waiting = await sync(catalog, "TEST_FIN")
assert waiting["status"] == "waiting_auth"
assert (await search(client, "/datasets/TEST_FIN/collection")) == manifest
await runner.disconnect()
assert (await client.get("/api/v1/sync-jobs/" + waiting["id"])).json()["status"] == "waiting_connection"
assert (await client.post(BASE + "/sync-jobs", json={"scope": SCOPE})).status_code == 409
# Explicit reconnect verifies the original account and resumes the same task.
state["persona"] = False
connect = (await client.post("/api/v1/account/connect")).json()
await runner.execute(connect["id"])
await runner.execute(waiting["id"])
assert (await client.get("/api/v1/sync-jobs/" + waiting["id"])).json()["status"] == "completed"