merge: integrate alpha management with main and sequence migration 0005

This commit is contained in:
yuxuanhui
2026-09-08 10:45:00 +08:00
56 changed files with 7453 additions and 55 deletions
+85 -2
View File
@@ -1,4 +1,4 @@
"""Read-only WorldQuant adapter. Authentication is the only allowed upstream POST.
"""WorldQuant adapter. Only authentication and explicit backtests allow upstream POST.
No upstream response body or request headers are included in exceptions: they may
contain credentials, cookies, or temporary authentication links.
@@ -7,6 +7,8 @@ contain credentials, cookies, or temporary authentication links.
import asyncio
import math
import random
import re
from contextvars import ContextVar
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from typing import Awaitable, Callable
@@ -27,6 +29,12 @@ class VerificationRequired(WqError):
self.url = url
class SimulationDeferred(WqError):
def __init__(self, message, delay=5, code="rate_limited"):
super().__init__(message, code)
self.delay = delay
class WqClient:
def __init__(self, settings, transport=None, sleep=asyncio.sleep):
self.settings = settings
@@ -45,7 +53,73 @@ class WqClient:
self.session_expires_at: datetime | None = None
self.session_duration: float | None = None
self.sleep = sleep
self.on_retry: Callable[[float], Awaitable[None]] | None = None
self._retry_hook = ContextVar("wq_retry_hook", default=None)
@property
def on_retry(self) -> Callable[[float], Awaitable[None]] | None:
return self._retry_hook.get()
@on_retry.setter
def on_retry(self, value):
# Sync and simulation tasks share a session, never each other's retry callback.
self._retry_hook.set(value)
def simulation_url(self, value):
"""Accept only same-origin simulation resources; never forward cookies elsewhere."""
base = urlparse(self.settings.wq_base_url)
url = urlparse(urljoin(self.settings.wq_base_url, value))
if (
url.scheme != base.scheme
or url.netloc != base.netloc
or url.query
or url.fragment
or not re.fullmatch(r"/simulations/[A-Za-z0-9_-]+", url.path)
):
raise WqError("模拟引用地址无法确认", "invalid_simulation_url")
return url.geturl()
async def submit_simulations(self, payload):
"""One POST only. Transport/5xx/invalid acknowledgement may already be accepted."""
try:
response = await self.client.post(
"/simulations", json=payload[0] if len(payload) == 1 else payload
)
except httpx.TransportError:
raise WqError("提交结果未知,禁止自动重提,请核对平台任务", "submission_unknown") from None
if response.status_code == 429:
raise SimulationDeferred(
"平台限流,暂停后续提交", self.retry_delay(response.headers.get("Retry-After"), 0)
)
if response.status_code == 401:
self.authenticated = False
raise SimulationDeferred("平台会话过期,重新认证后继续", 2, "session_expired")
if response.status_code in (400, 403, 404, 422):
raise WqError(f"平台拒绝回测提交(HTTP {response.status_code})", "submission_rejected")
if response.status_code != 201 or not response.headers.get("Location"):
raise WqError("平台未返回可靠提交凭证,请核对后再处理", "submission_unknown")
try:
return self.simulation_url(response.headers["Location"])
except WqError:
raise WqError("平台已响应但模拟引用无法确认,禁止自动重提", "submission_unknown") from None
async def poll_simulation(self, url):
response = await self._request("GET", self.simulation_url(url))
if response.status_code == 401:
self.authenticated = False
raise SimulationDeferred("平台会话过期,重新认证后继续", 2, "session_expired")
if response.status_code not in (200, 202):
raise WqError(
f"模拟查询失败(HTTP {response.status_code}),保留原任务", "simulation_unavailable"
)
try:
data = response.json()
if not isinstance(data, dict):
raise ValueError()
except ValueError:
raise WqError("模拟响应格式无法识别,保留原任务", "invalid_response") from None
return data, self.retry_delay(response.headers["Retry-After"], 0) if response.headers.get(
"Retry-After"
) else 0
async def close(self):
await self.client.aclose()
@@ -282,3 +356,12 @@ class WqClient:
async def pnl(self, alpha_id):
return await self.get(f"/alphas/{alpha_id}/recordsets/pnl")
async def catalog_page(self, scope, dataset_id, offset):
"""Read a single scoped page. IDs are query parameters, never upstream paths."""
params = {"instrumentType": scope["instrument_type"], "region": scope["region"],
"universe": scope["universe"], "delay": scope["delay"],
"limit": 50, "offset": offset}
if dataset_id is not None:
params["dataset.id"] = dataset_id
return await self.get("/data-fields" if dataset_id else "/data-sets", params)