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 <noreply@anthropic.com>
This commit is contained in:
@@ -18,46 +18,76 @@ def test_whoami_returns_token_daemon_id():
|
|||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
captured["url"] = str(req.url)
|
captured["url"] = str(req.url)
|
||||||
captured["auth"] = req.headers.get("authorization")
|
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",
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
client=_client(handler))
|
client=_client(handler))
|
||||||
assert adapter.whoami() == "d-7"
|
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"
|
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 = {}
|
captured = {}
|
||||||
|
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
captured["url"] = str(req.url)
|
captured["url"] = str(req.url)
|
||||||
return httpx.Response(200, json={
|
# live shape: {"portfolios": [PortfolioItem...], "total": N};
|
||||||
"items": [
|
# PortfolioItem has no active_strategy_id/strategy_params and the
|
||||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
# endpoint is token-scoped (no managed_by_daemon_id query param).
|
||||||
"cash_balance": 1_000_000, "asset_quantity": 10,
|
return httpx.Response(200, json={"portfolios": [
|
||||||
"active_strategy_id": "target-value-v1",
|
{"portfolio_id": "pf-1", "asset_id": "a-1", "symbol": "005930",
|
||||||
"strategy_params": {"target_value": 1_000_000}},
|
"cash_balance": 1_000_000, "asset_quantity": 10,
|
||||||
{"portfolio_id": "pf-2", "symbol": "000660",
|
"managed_by_daemon_id": "d-7", "price": 70_000},
|
||||||
"cash_balance": 2_000_000, "asset_quantity": 0,
|
{"portfolio_id": "pf-2", "asset_id": "a-2", "symbol": "000660",
|
||||||
"active_strategy_id": None, "strategy_params": {}},
|
"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",
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
client=_client(handler))
|
client=_client(handler))
|
||||||
items = adapter.get_portfolios("d-7")
|
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 {it.portfolio_id for it in items} == {"pf-1", "pf-2"}
|
||||||
assert items[0].symbol == "005930"
|
assert items[0].symbol == "005930"
|
||||||
assert items[0].cash_balance == 1_000_000
|
assert items[0].cash_balance == 1_000_000
|
||||||
assert items[0].active_strategy_id == "target-value-v1"
|
# strategy fields absent on PortfolioItem -> graceful defaults
|
||||||
assert items[0].strategy_params == {"target_value": 1_000_000}
|
assert items[0].active_strategy_id is None
|
||||||
# no price field
|
assert items[0].strategy_params == {}
|
||||||
|
# no price field on the domain item
|
||||||
for it in items:
|
for it in items:
|
||||||
assert not hasattr(it, "price")
|
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 test_whoami_5xx_raises_unavailable():
|
||||||
def handler(req):
|
def handler(req):
|
||||||
return httpx.Response(503, text="upstream down")
|
return httpx.Response(503, text="upstream down")
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4).
|
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4).
|
||||||
|
|
||||||
Mirrors portfolio-hub `list_portfolios_v1`:
|
Matches the live portfolio-hub HTTP contract:
|
||||||
GET /v1/portfolios?managed_by_daemon_id=<id>
|
GET /v1/user_info -> UserInfoResponse {"daemon_id": "...", ...}
|
||||||
GET /v1/auth/whoami (returns {"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
|
Credentials reused from BAT-006 (TREX_PORTFOLIO_HUB_*). All failures lift
|
||||||
to PortfolioHubUnavailableError so the snapshot store can set the degraded
|
to PortfolioHubUnavailableError so the snapshot store can set the degraded
|
||||||
@@ -29,7 +34,10 @@ class PortfolioHubReadAdapter:
|
|||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
client: Optional[httpx.Client] = None,
|
client: Optional[httpx.Client] = None,
|
||||||
) -> 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._token = token
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._client = client
|
self._client = client
|
||||||
@@ -48,30 +56,30 @@ class PortfolioHubReadAdapter:
|
|||||||
timeout=self._timeout)
|
timeout=self._timeout)
|
||||||
|
|
||||||
def whoami(self) -> str:
|
def whoami(self) -> str:
|
||||||
url = f"{self._base_url}/v1/auth/whoami"
|
url = f"{self._base_url}/v1/user_info"
|
||||||
try:
|
try:
|
||||||
resp = self._get(url)
|
resp = self._get(url)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise PortfolioHubUnavailableError(
|
raise PortfolioHubUnavailableError(
|
||||||
f"whoami network error: {exc.__class__.__name__}"
|
f"user_info network error: {exc.__class__.__name__}"
|
||||||
) from exc
|
) from exc
|
||||||
if not (200 <= resp.status_code < 300):
|
if not (200 <= resp.status_code < 300):
|
||||||
raise PortfolioHubUnavailableError(
|
raise PortfolioHubUnavailableError(
|
||||||
f"whoami {resp.status_code}: {resp.text[:120]}"
|
f"user_info {resp.status_code}: {resp.text[:120]}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
except ValueError as exc:
|
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
|
did = body.get("daemon_id") if isinstance(body, dict) else None
|
||||||
if not did:
|
if not did:
|
||||||
raise PortfolioHubUnavailableError("whoami missing daemon_id")
|
raise PortfolioHubUnavailableError("user_info missing daemon_id")
|
||||||
return str(did)
|
return str(did)
|
||||||
|
|
||||||
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
url = f"{self._base_url}/v1/portfolios"
|
url = f"{self._base_url}/v1/portfolios"
|
||||||
try:
|
try:
|
||||||
resp = self._get(url, params={"managed_by_daemon_id": daemon_id})
|
resp = self._get(url)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise PortfolioHubUnavailableError(
|
raise PortfolioHubUnavailableError(
|
||||||
f"get_portfolios network error: {exc.__class__.__name__}"
|
f"get_portfolios network error: {exc.__class__.__name__}"
|
||||||
@@ -85,7 +93,10 @@ class PortfolioHubReadAdapter:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise PortfolioHubUnavailableError("get_portfolios not JSON") from 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):
|
if not isinstance(items_raw, list):
|
||||||
raise PortfolioHubUnavailableError("get_portfolios shape unexpected")
|
raise PortfolioHubUnavailableError("get_portfolios shape unexpected")
|
||||||
|
|
||||||
@@ -93,6 +104,9 @@ class PortfolioHubReadAdapter:
|
|||||||
for raw in items_raw:
|
for raw in items_raw:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
continue
|
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 "")
|
pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "")
|
||||||
if not pid:
|
if not pid:
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user