2026-09-07 14:54:20 +08:00
|
|
|
"""Isolated browser acceptance server: temporary database, synthetic upstream, no real network."""
|
|
|
|
|
|
2026-09-08 21:29:21 +08:00
|
|
|
import os
|
2026-09-07 14:54:20 +08:00
|
|
|
import tempfile
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from cryptography.fernet import Fernet
|
|
|
|
|
|
|
|
|
|
from app.config import Settings
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
from app.models import Base
|
|
|
|
|
from app.worldquant import WqClient
|
2026-09-07 23:02:55 +08:00
|
|
|
from tests.ai_fake import fake_model
|
2026-09-08 10:06:00 +08:00
|
|
|
from tests.backtest_fake import Platform
|
2026-09-08 09:22:53 +08:00
|
|
|
from tests.catalog_fake import catalog_response
|
2026-09-08 21:29:21 +08:00
|
|
|
from tests.research_metadata_fake import response as research_metadata_response
|
2026-09-07 14:54:20 +08:00
|
|
|
|
|
|
|
|
TEST_PASSWORD = "browser-test-password"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sample(index):
|
|
|
|
|
super_alpha = index % 7 == 0
|
|
|
|
|
language = "PYTHON" if index % 11 == 0 and not super_alpha else "FASTEXPR"
|
|
|
|
|
names = [
|
|
|
|
|
"短期价格反转",
|
|
|
|
|
"成交量与价格背离",
|
|
|
|
|
"分析师预期修正",
|
|
|
|
|
"行业中性动量",
|
|
|
|
|
"低波动质量信号",
|
|
|
|
|
"资金流向偏离",
|
|
|
|
|
"组合选择实验",
|
|
|
|
|
]
|
|
|
|
|
return {
|
|
|
|
|
"id": f"TEST{index:04}",
|
|
|
|
|
"name": names[index % len(names)] + f" · {index:03}",
|
|
|
|
|
"type": "SUPER" if super_alpha else "REGULAR",
|
|
|
|
|
"stage": "OS" if index % 3 == 0 else "IS",
|
|
|
|
|
"status": "ACTIVE" if index % 3 == 0 else "UNSUBMITTED",
|
|
|
|
|
"hidden": index % 5 == 0,
|
|
|
|
|
"regular": None
|
|
|
|
|
if super_alpha
|
|
|
|
|
else {
|
|
|
|
|
"code": "def alpha(data):\n return -data.close.rank()"
|
|
|
|
|
if language == "PYTHON"
|
|
|
|
|
else "group_neutralize(-rank(ts_delta(close, 5)), industry)"
|
|
|
|
|
},
|
|
|
|
|
"selection": {"code": "self_correlation < 0.5"} if super_alpha else None,
|
|
|
|
|
"combo": {"code": "alpha"} if super_alpha else None,
|
|
|
|
|
"settings": {
|
2026-09-08 10:40:49 +08:00
|
|
|
"region": ["USA", "CHN", "EUR"][(index // 3) % 3],
|
2026-09-07 14:54:20 +08:00
|
|
|
"universe": "TOP3000",
|
|
|
|
|
"language": language,
|
|
|
|
|
"delay": 1,
|
|
|
|
|
"decay": 4,
|
|
|
|
|
"neutralization": "INDUSTRY",
|
|
|
|
|
"truncation": 0.08,
|
|
|
|
|
"pasteurization": "ON",
|
|
|
|
|
},
|
|
|
|
|
"is": {
|
|
|
|
|
"sharpe": None if index % 13 == 0 else round(0.8 + (index % 25) * 0.1, 2),
|
|
|
|
|
"fitness": round(0.3 + (index % 20) * 0.06, 2),
|
|
|
|
|
"turnover": round(0.08 + (index % 12) * 0.015, 3),
|
|
|
|
|
"returns": 0.09,
|
|
|
|
|
"margin": 0.0002,
|
|
|
|
|
"drawdown": 0.11,
|
|
|
|
|
"checks": [{"name": "LOW_SHARPE", "result": "PASS", "value": 2.1, "limit": 1.58}],
|
|
|
|
|
},
|
|
|
|
|
"os": {"sharpe": 1.1} if index % 3 == 0 else None,
|
2026-09-08 10:40:49 +08:00
|
|
|
"dateCreated": (datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=index)).isoformat(),
|
|
|
|
|
"dateSubmitted": (datetime(2025, 2, 1, tzinfo=timezone.utc) + timedelta(days=index % 2)).isoformat()
|
|
|
|
|
if index % 3 == 0
|
|
|
|
|
else None,
|
2026-09-07 14:54:20 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_test_app():
|
|
|
|
|
temporary = tempfile.TemporaryDirectory(prefix="wq-browser-test-")
|
|
|
|
|
settings = Settings(
|
|
|
|
|
_env_file=None,
|
|
|
|
|
database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db",
|
|
|
|
|
admin_password=TEST_PASSWORD,
|
2026-09-09 16:18:27 +08:00
|
|
|
mcp_enabled=True,
|
2026-09-07 14:54:20 +08:00
|
|
|
encryption_key=Fernet.generate_key().decode(),
|
2026-09-08 21:29:21 +08:00
|
|
|
public_origin=os.environ.get("BROWSER_TEST_ORIGIN", "http://127.0.0.1:5179"),
|
2026-09-07 14:54:20 +08:00
|
|
|
)
|
|
|
|
|
records = [sample(i) for i in range(620)]
|
2026-09-08 10:06:00 +08:00
|
|
|
simulations = Platform()
|
|
|
|
|
simulations.existing_alpha_ids = [f"TEST{i:04}" for i in range(1, 100)]
|
2026-09-07 14:54:20 +08:00
|
|
|
|
|
|
|
|
def upstream(request):
|
2026-09-08 21:29:21 +08:00
|
|
|
metadata = research_metadata_response(request)
|
|
|
|
|
if metadata is not None:
|
|
|
|
|
return metadata
|
2026-09-07 14:54:20 +08:00
|
|
|
path = request.url.path
|
|
|
|
|
if path == "/authentication" and request.method == "POST":
|
|
|
|
|
return httpx.Response(
|
2026-09-07 23:31:23 +08:00
|
|
|
201,
|
|
|
|
|
json={
|
|
|
|
|
"user": {"id": "TEST_USER"},
|
|
|
|
|
"token": {"expiry": 14400},
|
|
|
|
|
"permissions": [
|
|
|
|
|
"CONSULTANT",
|
|
|
|
|
"SUPER_ALPHA",
|
|
|
|
|
"MULTI_SIMULATION",
|
|
|
|
|
"PROD_ALPHAS",
|
|
|
|
|
"BRAIN_LABS",
|
|
|
|
|
"BRAIN_LABS_JUPYTER_LAB",
|
|
|
|
|
"REFERRAL",
|
|
|
|
|
"VISUALIZATION",
|
|
|
|
|
"WORKDAY",
|
|
|
|
|
"BEFORE_AND_AFTER_PERFORMANCE_V2",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
headers={"Set-Cookie": "mock=only; Path=/"},
|
2026-09-07 14:54:20 +08:00
|
|
|
)
|
2026-09-08 18:57:03 +08:00
|
|
|
if request.method == "OPTIONS":
|
|
|
|
|
return catalog_response(request) or httpx.Response(404)
|
2026-09-08 10:06:00 +08:00
|
|
|
if path.startswith("/simulations") or (
|
|
|
|
|
path.startswith("/alphas/") and path.rsplit("/", 1)[-1] in simulations.alphas
|
|
|
|
|
):
|
|
|
|
|
return simulations(request)
|
2026-09-07 14:54:20 +08:00
|
|
|
if request.method != "GET":
|
|
|
|
|
raise AssertionError("Browser acceptance attempted an upstream mutation")
|
2026-09-08 09:22:53 +08:00
|
|
|
catalog = catalog_response(request)
|
|
|
|
|
if catalog is not None:
|
|
|
|
|
return catalog
|
2026-09-07 14:54:20 +08:00
|
|
|
if path == "/users/self":
|
|
|
|
|
return httpx.Response(
|
2026-09-07 23:31:23 +08:00
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"id": "TEST_USER",
|
|
|
|
|
"fullName": "模拟研究员",
|
|
|
|
|
"email": "test@example.com",
|
|
|
|
|
"level": "CONSULTANT",
|
|
|
|
|
"geniusLevel": "GOLD",
|
|
|
|
|
"verified": True,
|
|
|
|
|
"approved": True,
|
|
|
|
|
"dateCreated": "2025-01-01T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if path == "/users/self/alphas/summary":
|
|
|
|
|
return httpx.Response(200, json={"unsubmitted": 413, "active": 207, "decommissioned": 0})
|
2026-09-09 18:20:08 +08:00
|
|
|
if path in ("/users/self/activities/pyramid-multipliers", "/users/self/activities/pyramid-alphas"):
|
|
|
|
|
metric = "multiplier" if path.endswith("multipliers") else "alphaCount"
|
|
|
|
|
categories = [
|
|
|
|
|
("analyst", "Analyst", "USA", 1, 1.8, 12),
|
|
|
|
|
("fundamental", "Fundamental", "EUR", 1, 1.6, 7),
|
|
|
|
|
("model", "Model", "GLB", 1, 1.5, 5),
|
|
|
|
|
("sentiment", "Sentiment", "CHN", 0, 1.4, 3),
|
|
|
|
|
("news", "News", "ASI", 1, 1.2, 0),
|
|
|
|
|
("pv", "Price Volume", "USA", 0, 1.0, 0),
|
|
|
|
|
]
|
|
|
|
|
return httpx.Response(200, json={"pyramids": [
|
|
|
|
|
{"category": {"id": cid, "name": name}, "region": region, "delay": delay,
|
|
|
|
|
metric: multiplier if metric == "multiplier" else min(count, 2) if request.url.query else count}
|
|
|
|
|
for cid, name, region, delay, multiplier, count in categories
|
|
|
|
|
]})
|
2026-09-07 23:31:23 +08:00
|
|
|
if path in ("/users/self/activities/simulations", "/users/self/activities/submissions"):
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
today = datetime.now(ZoneInfo("America/New_York")).date()
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"yesterday": {"end": (today - timedelta(days=1)).isoformat(), "value": 2},
|
|
|
|
|
"total": {"value": 207},
|
|
|
|
|
"records": {
|
|
|
|
|
"schema": {"properties": [{"name": "date"}, {"name": "value"}]},
|
|
|
|
|
"records": [[today.isoformat(), 3]],
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-09-07 14:54:20 +08:00
|
|
|
)
|
|
|
|
|
if path == "/users/self/alphas":
|
|
|
|
|
unsubmitted = "status" in request.url.params
|
|
|
|
|
hidden = request.url.params.get("hidden") == "true"
|
|
|
|
|
matched = [
|
|
|
|
|
r for r in records if (r["status"] == "UNSUBMITTED") == unsubmitted and r["hidden"] == hidden
|
|
|
|
|
]
|
2026-09-08 10:40:49 +08:00
|
|
|
for field in ("dateCreated", "dateSubmitted"):
|
2026-09-09 15:12:45 +08:00
|
|
|
if field + ">=" in request.url.params:
|
|
|
|
|
return httpx.Response(400, json=["Expected ISO 8601 datetime with timezone"])
|
|
|
|
|
for suffix in (">", "<"):
|
2026-09-08 10:40:49 +08:00
|
|
|
boundary = request.url.params.get(field + suffix)
|
|
|
|
|
if boundary:
|
|
|
|
|
bound = datetime.fromisoformat(boundary).replace(tzinfo=timezone.utc)
|
|
|
|
|
matched = [
|
|
|
|
|
r
|
|
|
|
|
for r in matched
|
|
|
|
|
if r.get(field)
|
|
|
|
|
and (
|
|
|
|
|
datetime.fromisoformat(r[field]) >= bound
|
2026-09-09 15:12:45 +08:00
|
|
|
if suffix == ">"
|
|
|
|
|
else datetime.fromisoformat(r[field]) <= bound
|
2026-09-08 10:40:49 +08:00
|
|
|
)
|
|
|
|
|
]
|
2026-09-07 14:54:20 +08:00
|
|
|
offset, limit = (
|
|
|
|
|
int(request.url.params.get("offset", 0)),
|
|
|
|
|
int(request.url.params.get("limit", 100)),
|
|
|
|
|
)
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200, json={"count": len(matched), "results": matched[offset : offset + limit]}
|
|
|
|
|
)
|
|
|
|
|
if path.endswith("/recordsets/pnl"):
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
|
|
|
|
"records": [
|
|
|
|
|
[
|
|
|
|
|
(datetime(2025, 1, 1) + timedelta(days=i)).date().isoformat(),
|
|
|
|
|
i * 250 + (i % 13) * 85,
|
|
|
|
|
]
|
|
|
|
|
for i in range(180)
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
record = next((r for r in records if path == f"/alphas/{r['id']}"), None)
|
|
|
|
|
return httpx.Response(200, json=record) if record else httpx.Response(404)
|
|
|
|
|
|
2026-09-07 23:02:55 +08:00
|
|
|
application = create_app(
|
|
|
|
|
settings, WqClient(settings, transport=httpx.MockTransport(upstream)), ai_model_factory=fake_model
|
|
|
|
|
)
|
2026-09-09 16:18:27 +08:00
|
|
|
# Test-only fixture, excluded from production images by .dockerignore.
|
|
|
|
|
from fastapi import Depends
|
|
|
|
|
|
|
|
|
|
from app.mcp_api.auth import SCOPES, create_token
|
|
|
|
|
from app.security import require_auth
|
|
|
|
|
|
|
|
|
|
@application.post("/api/v1/test/mcp-token", dependencies=[Depends(require_auth)])
|
|
|
|
|
async def synthetic_token():
|
|
|
|
|
async with application.state.sessions.begin() as db:
|
|
|
|
|
_, token = await create_token(db, "browser-fixture", SCOPES)
|
|
|
|
|
return {"token": token}
|
|
|
|
|
|
2026-09-07 14:54:20 +08:00
|
|
|
original_lifespan = application.router.lifespan_context
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app):
|
|
|
|
|
async with app.state.engine.begin() as connection:
|
|
|
|
|
await connection.run_sync(Base.metadata.create_all)
|
|
|
|
|
async with original_lifespan(app):
|
|
|
|
|
yield
|
|
|
|
|
temporary.cleanup()
|
|
|
|
|
|
|
|
|
|
application.router.lifespan_context = lifespan
|
|
|
|
|
return application
|