From 3d85b073a41431827f3d2cbb13c8c83087a24689 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Wed, 9 Sep 2026 19:34:08 +0800 Subject: [PATCH] fix: extend polling budget for slow WorldQuant PnL --- backend/app/config.py | 1 + backend/app/worldquant.py | 16 ++++++++--- backend/tests/test_worldquant.py | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 4b381b7..76959bc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): wq_base_url: str = "https://api.worldquantbrain.com" request_timeout: float = 30 retry_attempts: int = Field(default=4, ge=1, le=8) + pnl_poll_attempts: int = Field(default=120, ge=1, le=1000) enable_runner: bool = True mcp_enabled: bool = False ai_request_limit: int = Field(default=12, ge=1, le=30) diff --git a/backend/app/worldquant.py b/backend/app/worldquant.py index 9a997bc..d1475e3 100644 --- a/backend/app/worldquant.py +++ b/backend/app/worldquant.py @@ -268,14 +268,15 @@ class WqClient: async def get(self, path: str, params=None, headers=None): return await self._read_json("GET", path, params=params, headers=headers) - async def _read_json(self, method: str, path: str, *, allow_list=False, **kwargs): + async def _read_json(self, method: str, path: str, *, allow_list=False, poll_attempts=None, **kwargs): """Authenticated read with shared refresh/retry handling; callers use GET or OPTIONS.""" if not self.credentials: raise WqError("请先连接 WorldQuant", "disconnected") if not self.authenticated: await self.authenticate(*self.credentials) refreshed = False - for attempt in range(self.settings.retry_attempts): + attempts = self.settings.retry_attempts if poll_attempts is None else poll_attempts + for attempt in range(attempts): generation = self.auth_generation response = await self._request(method, path, **kwargs) if response.status_code == 401 and not refreshed: @@ -293,7 +294,7 @@ class WqClient: response.headers.get("Retry-After") and self.retry_delay(response.headers["Retry-After"], 0) > 0 ): - if attempt + 1 == self.settings.retry_attempts: + if attempt + 1 == attempts: raise WqError("平台数据仍在准备,请稍后重试", "pending") delay = self.retry_delay(response.headers["Retry-After"], attempt) if self.on_retry: @@ -390,7 +391,14 @@ class WqClient: return await self.get(f"/alphas/{alpha_id}") async def pnl(self, alpha_id): - return await self.get(f"/alphas/{alpha_id}/recordsets/pnl") + """Wait for slow PnL generation separately from transport-error retries. + + Each pending response uses the platform's Retry-After delay. Exhausting + the bounded polling budget raises WqError with code 'pending'. + """ + return await self._read_json( + "GET", f"/alphas/{alpha_id}/recordsets/pnl", poll_attempts=self.settings.pnl_poll_attempts + ) async def catalog_page(self, scope, dataset_id, offset): """Read a single scoped page. IDs are query parameters, never upstream paths.""" diff --git a/backend/tests/test_worldquant.py b/backend/tests/test_worldquant.py index 04bfb83..77bc838 100644 --- a/backend/tests/test_worldquant.py +++ b/backend/tests/test_worldquant.py @@ -265,3 +265,49 @@ async def test_concurrent_expiry_authenticates_once(settings): assert await asyncio.gather(client.profile(), client.profile()) == [{"id": "user"}, {"id": "user"}] assert auth_count == 2 # Initial connection plus one shared reauthentication. await client.close() + + +async def test_slow_pnl_waits_beyond_network_retry_budget(settings): + calls, delays = 0, [] + + async def sleep(delay): + delays.append(delay) + + def handler(request): + nonlocal calls + calls += 1 + assert request.url.path == '/alphas/LL977PqL/recordsets/pnl' + if calls <= 6: + return httpx.Response(200, headers={'Retry-After': '1.0'}) + return httpx.Response(200, json={'records': []}) + + client = WqClient(settings, transport=httpx.MockTransport(handler), sleep=sleep) + client.credentials, client.authenticated = ('test@example.com', 'secret'), True + try: + assert await client.pnl('LL977PqL') == {'records': []} + assert calls == 7 + assert delays == [1.0] * 6 + finally: + await client.close() + + +async def test_pnl_polling_remains_bounded(settings): + settings.pnl_poll_attempts = 5 + delays = [] + + async def sleep(delay): + delays.append(delay) + + client = WqClient( + settings, + transport=httpx.MockTransport(lambda r: httpx.Response(200, headers={'Retry-After': '1.0'})), + sleep=sleep, + ) + client.credentials, client.authenticated = ('test@example.com', 'secret'), True + try: + with pytest.raises(WqError) as error: + await client.pnl('LL977PqL') + assert error.value.code == 'pending' + assert delays == [1.0] * 4 + finally: + await client.close()