feat: add portfolio snapshot read layer

This commit is contained in:
2026-06-06 09:33:00 +09:00
parent 56e977a7ad
commit ca757009f5
51 changed files with 3565 additions and 51 deletions
+6
View File
@@ -0,0 +1,6 @@
"""portfolio-hub read adapters (REQ-027, dcy-005)."""
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
__all__ = ["PortfolioHubReadAdapter", "PostgreSQLPauseStore", "StubPortfolioRead"]
+107
View File
@@ -0,0 +1,107 @@
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4).
Mirrors portfolio-hub `list_portfolios_v1`:
GET /v1/portfolios?managed_by_daemon_id=<id>
GET /v1/auth/whoami (returns {"daemon_id": "..."})
Credentials reused from BAT-006 (TREX_PORTFOLIO_HUB_*). All failures lift
to PortfolioHubUnavailableError so the snapshot store can set the degraded
signal (REQ-028 AC-2) without callers seeing raw httpx exceptions.
"""
from __future__ import annotations
from typing import Optional
import httpx
from trex_core.application.portfolio import (
PortfolioHubUnavailableError,
PortfolioItem,
)
class PortfolioHubReadAdapter:
def __init__(
self,
base_url: str,
*,
token: Optional[str] = None,
timeout: float = 5.0,
client: Optional[httpx.Client] = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._token = token
self._timeout = timeout
self._client = client
def _headers(self) -> dict[str, str]:
h = {"accept": "application/json"}
if self._token:
h["authorization"] = f"Bearer {self._token}"
return h
def _get(self, url: str, *, params: Optional[dict] = None) -> httpx.Response:
if self._client is not None:
return self._client.get(url, params=params, headers=self._headers(),
timeout=self._timeout)
return httpx.get(url, params=params, headers=self._headers(),
timeout=self._timeout)
def whoami(self) -> str:
url = f"{self._base_url}/v1/auth/whoami"
try:
resp = self._get(url)
except httpx.HTTPError as exc:
raise PortfolioHubUnavailableError(
f"whoami network error: {exc.__class__.__name__}"
) from exc
if not (200 <= resp.status_code < 300):
raise PortfolioHubUnavailableError(
f"whoami {resp.status_code}: {resp.text[:120]}"
)
try:
body = resp.json()
except ValueError as exc:
raise PortfolioHubUnavailableError("whoami 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")
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})
except httpx.HTTPError as exc:
raise PortfolioHubUnavailableError(
f"get_portfolios network error: {exc.__class__.__name__}"
) from exc
if not (200 <= resp.status_code < 300):
raise PortfolioHubUnavailableError(
f"get_portfolios {resp.status_code}: {resp.text[:120]}"
)
try:
body = resp.json()
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 not isinstance(items_raw, list):
raise PortfolioHubUnavailableError("get_portfolios shape unexpected")
out: list[PortfolioItem] = []
for raw in items_raw:
if not isinstance(raw, dict):
continue
pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "")
if not pid:
continue
out.append(PortfolioItem(
portfolio_id=pid,
symbol=str(raw.get("symbol") or ""),
cash_balance=float(raw.get("cash_balance") or 0.0),
asset_quantity=float(raw.get("asset_quantity") or 0.0),
active_strategy_id=raw.get("active_strategy_id") or None,
strategy_params=dict(raw.get("strategy_params") or {}),
))
return out
@@ -0,0 +1,42 @@
"""PostgreSQL-backed durable pause store (REQ-032 AC-2)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from trex_core.adapters.persistence.postgresql import _require_psycopg
class PostgreSQLPauseStore:
"""`snapshot_pause` table CRUD. portfolio_id PK is the gate."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
def list_paused(self) -> set[str]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute("SELECT portfolio_id FROM snapshot_pause")
return {row[0] for row in cur.fetchall()}
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO snapshot_pause (portfolio_id, actor) "
"VALUES (%s, %s) "
"ON CONFLICT (portfolio_id) DO UPDATE "
"SET actor = EXCLUDED.actor, updated_at = %s",
(portfolio_id, actor, datetime.now(tz=timezone.utc)),
)
conn.commit()
def resume(self, portfolio_id: str) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM snapshot_pause WHERE portfolio_id = %s",
(portfolio_id,),
)
conn.commit()
+44
View File
@@ -0,0 +1,44 @@
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2)."""
from __future__ import annotations
from trex_core.application.portfolio import PortfolioItem
class StubPortfolioRead:
"""Returns a fixed universe — no network required.
`whoami()` returns the configured daemon_id; tests can override the
universe by mutating `self.items`.
"""
def __init__(
self,
daemon_id: str = "stub-daemon",
items: list[PortfolioItem] | None = None,
) -> None:
self._daemon_id = daemon_id
self.items: list[PortfolioItem] = list(items or [
PortfolioItem(
portfolio_id="pf-005930",
symbol="005930",
cash_balance=10_000_000.0,
asset_quantity=0.0,
active_strategy_id="target-value-v1",
strategy_params={"target_value": 1_000_000},
),
PortfolioItem(
portfolio_id="pf-000660",
symbol="000660",
cash_balance=5_000_000.0,
asset_quantity=10.0,
active_strategy_id="trend-follow-v1",
strategy_params={"window": 20},
),
])
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
# Stub does not filter by daemon_id — every test universe is scoped.
return list(self.items)
def whoami(self) -> str:
return self._daemon_id