From 43bad2424e5f04f8029057048f4900bda6776e89 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Sat, 6 Jun 2026 10:34:35 +0900 Subject: [PATCH] fix: align portfolio-hub read adapter to live API contract whoami now hits GET /v1/user_info (the live endpoint; /v1/auth/whoami does not exist), get_portfolios parses the {portfolios:[...],total} envelope and filters by managed_by_daemon_id client-side (the list endpoint is token-scoped, no such query param), and a missing URL scheme now defaults to http:// so a bare host can't silently degrade the daemon. Co-Authored-By: Claude Opus 4.7 --- tests/adapters/portfolio/test_hub_read.py | 66 ++++++++++++++++------- trex_core/adapters/portfolio/hub_read.py | 36 +++++++++---- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/tests/adapters/portfolio/test_hub_read.py b/tests/adapters/portfolio/test_hub_read.py index 1888ab5..ad2f3d3 100644 --- a/tests/adapters/portfolio/test_hub_read.py +++ b/tests/adapters/portfolio/test_hub_read.py @@ -18,46 +18,76 @@ def test_whoami_returns_token_daemon_id(): def handler(req: httpx.Request) -> httpx.Response: captured["url"] = str(req.url) captured["auth"] = req.headers.get("authorization") - return httpx.Response(200, json={"daemon_id": "d-7", "scope": ["read"]}) + return httpx.Response(200, json={"daemon_id": "d-7", "user_id": "u-1", + "username": "bot", "trading_enabled": True}) adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) assert adapter.whoami() == "d-7" - assert captured["url"] == "https://pf.test/v1/auth/whoami" + assert captured["url"] == "https://pf.test/v1/user_info" assert captured["auth"] == "Bearer tok" -def test_get_portfolios_scoped_by_managed_by_daemon_id(): +def test_get_portfolios_parses_envelope_and_filters_by_daemon(): captured = {} def handler(req: httpx.Request) -> httpx.Response: captured["url"] = str(req.url) - return httpx.Response(200, json={ - "items": [ - {"portfolio_id": "pf-1", "symbol": "005930", - "cash_balance": 1_000_000, "asset_quantity": 10, - "active_strategy_id": "target-value-v1", - "strategy_params": {"target_value": 1_000_000}}, - {"portfolio_id": "pf-2", "symbol": "000660", - "cash_balance": 2_000_000, "asset_quantity": 0, - "active_strategy_id": None, "strategy_params": {}}, - ], - }) + # live shape: {"portfolios": [PortfolioItem...], "total": N}; + # PortfolioItem has no active_strategy_id/strategy_params and the + # endpoint is token-scoped (no managed_by_daemon_id query param). + return httpx.Response(200, json={"portfolios": [ + {"portfolio_id": "pf-1", "asset_id": "a-1", "symbol": "005930", + "cash_balance": 1_000_000, "asset_quantity": 10, + "managed_by_daemon_id": "d-7", "price": 70_000}, + {"portfolio_id": "pf-2", "asset_id": "a-2", "symbol": "000660", + "cash_balance": 2_000_000, "asset_quantity": 0, + "managed_by_daemon_id": "d-7"}, + {"portfolio_id": "pf-other", "symbol": "035720", + "cash_balance": 0, "asset_quantity": 0, + "managed_by_daemon_id": "d-OTHER"}, + ], "total": 3}) adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler)) items = adapter.get_portfolios("d-7") - assert "managed_by_daemon_id=d-7" in captured["url"] + assert captured["url"] == "https://pf.test/v1/portfolios" + # client-side filter drops the other daemon's portfolio assert {it.portfolio_id for it in items} == {"pf-1", "pf-2"} assert items[0].symbol == "005930" assert items[0].cash_balance == 1_000_000 - assert items[0].active_strategy_id == "target-value-v1" - assert items[0].strategy_params == {"target_value": 1_000_000} - # no price field + # strategy fields absent on PortfolioItem -> graceful defaults + assert items[0].active_strategy_id is None + assert items[0].strategy_params == {} + # no price field on the domain item for it in items: assert not hasattr(it, "price") +def test_get_portfolios_includes_items_without_daemon_field(): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"portfolios": [ + {"portfolio_id": "pf-1", "symbol": "005930", + "cash_balance": 0, "asset_quantity": 0}, + ], "total": 1}) + + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + items = adapter.get_portfolios("d-7") + assert {it.portfolio_id for it in items} == {"pf-1"} + + +def test_base_url_without_scheme_defaults_to_http(): + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["url"] = str(req.url) + return httpx.Response(200, json={"daemon_id": "d-7"}) + + adapter = PortfolioHubReadAdapter("127.0.0.1:18000", client=_client(handler)) + adapter.whoami() + assert captured["url"] == "http://127.0.0.1:18000/v1/user_info" + + def test_whoami_5xx_raises_unavailable(): def handler(req): return httpx.Response(503, text="upstream down") diff --git a/trex_core/adapters/portfolio/hub_read.py b/trex_core/adapters/portfolio/hub_read.py index 608c732..0655c63 100644 --- a/trex_core/adapters/portfolio/hub_read.py +++ b/trex_core/adapters/portfolio/hub_read.py @@ -1,8 +1,13 @@ """portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4). -Mirrors portfolio-hub `list_portfolios_v1`: - GET /v1/portfolios?managed_by_daemon_id= - GET /v1/auth/whoami (returns {"daemon_id": "..."}) +Matches the live portfolio-hub HTTP contract: + GET /v1/user_info -> UserInfoResponse {"daemon_id": "...", ...} + GET /v1/portfolios -> PortfolioListResponse {"portfolios": [PortfolioItem], "total": N} + +The list endpoint is token-scoped (no `managed_by_daemon_id` query param); +results are filtered client-side by `managed_by_daemon_id == daemon_id`. +`PortfolioItem` carries no `active_strategy_id`/`strategy_params`, so those +default to None/{} (opaque pass-through stays empty until sourced elsewhere). Credentials reused from BAT-006 (TREX_PORTFOLIO_HUB_*). All failures lift to PortfolioHubUnavailableError so the snapshot store can set the degraded @@ -29,7 +34,10 @@ class PortfolioHubReadAdapter: timeout: float = 5.0, client: Optional[httpx.Client] = None, ) -> None: - self._base_url = base_url.rstrip("/") + normalized = base_url.strip() + if "://" not in normalized: + normalized = "http://" + normalized + self._base_url = normalized.rstrip("/") self._token = token self._timeout = timeout self._client = client @@ -48,30 +56,30 @@ class PortfolioHubReadAdapter: timeout=self._timeout) def whoami(self) -> str: - url = f"{self._base_url}/v1/auth/whoami" + url = f"{self._base_url}/v1/user_info" try: resp = self._get(url) except httpx.HTTPError as exc: raise PortfolioHubUnavailableError( - f"whoami network error: {exc.__class__.__name__}" + f"user_info network error: {exc.__class__.__name__}" ) from exc if not (200 <= resp.status_code < 300): raise PortfolioHubUnavailableError( - f"whoami {resp.status_code}: {resp.text[:120]}" + f"user_info {resp.status_code}: {resp.text[:120]}" ) try: body = resp.json() except ValueError as exc: - raise PortfolioHubUnavailableError("whoami response not JSON") from exc + raise PortfolioHubUnavailableError("user_info response not JSON") from exc did = body.get("daemon_id") if isinstance(body, dict) else None if not did: - raise PortfolioHubUnavailableError("whoami missing daemon_id") + raise PortfolioHubUnavailableError("user_info missing daemon_id") return str(did) def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]: url = f"{self._base_url}/v1/portfolios" try: - resp = self._get(url, params={"managed_by_daemon_id": daemon_id}) + resp = self._get(url) except httpx.HTTPError as exc: raise PortfolioHubUnavailableError( f"get_portfolios network error: {exc.__class__.__name__}" @@ -85,7 +93,10 @@ class PortfolioHubReadAdapter: except ValueError as exc: raise PortfolioHubUnavailableError("get_portfolios not JSON") from exc - items_raw = body.get("items", body) if isinstance(body, dict) else body + if isinstance(body, dict): + items_raw = body.get("portfolios", body.get("items")) + else: + items_raw = body if not isinstance(items_raw, list): raise PortfolioHubUnavailableError("get_portfolios shape unexpected") @@ -93,6 +104,9 @@ class PortfolioHubReadAdapter: for raw in items_raw: if not isinstance(raw, dict): continue + mbd = raw.get("managed_by_daemon_id") + if mbd is not None and str(mbd) != daemon_id: + continue pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "") if not pid: continue