feat: add durable WorldQuant backtests with UI and AI confirmation
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user