feat: add portfolio snapshot read layer
This commit is contained in:
@@ -16,6 +16,7 @@ from typing import Optional
|
||||
import httpx
|
||||
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -196,6 +197,43 @@ class KISBroker:
|
||||
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None, raw=row)
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
|
||||
|
||||
Aggregates `output1[*].pdno` + `hldg_qty`, drops zero-qty rows
|
||||
(D-2 leftover, Design Decision 7). Network/credential errors map to
|
||||
an empty list (caller treats as no broker positions).
|
||||
"""
|
||||
cano, product = self._account()
|
||||
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
|
||||
params = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"AFHR_FLPR_YN": "N", "OFL_YN": "",
|
||||
"INQR_DVSN": "02", "UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
headers=self._headers(tr_id), params=params, timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return []
|
||||
if str(payload.get("rt_cd")) != "0":
|
||||
return []
|
||||
rows = payload.get("output1") or []
|
||||
out: list[BalanceItem] = []
|
||||
for row in rows:
|
||||
symbol = str(row.get("pdno") or "").strip()
|
||||
qty = _safe_int(row.get("hldg_qty")) or 0
|
||||
if not symbol or qty <= 0:
|
||||
continue
|
||||
out.append(BalanceItem(symbol=symbol, hldg_qty=qty))
|
||||
return out
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||
cano, product = self._account()
|
||||
parts = broker_order_id.split(":")
|
||||
|
||||
@@ -11,6 +11,7 @@ import threading
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -94,3 +95,19 @@ class PaperBroker:
|
||||
return False
|
||||
o.cancelled = True
|
||||
return True
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
"""Deterministic per-symbol fill aggregate (REQ-031 AC-3).
|
||||
|
||||
Aggregates buys − sells across all filled (or partially filled)
|
||||
paper orders, drops zero-qty rows (Design Decision 7).
|
||||
"""
|
||||
with self._lock:
|
||||
balances: dict[str, int] = {}
|
||||
for o in self._orders.values():
|
||||
if o.filled <= 0:
|
||||
continue
|
||||
sign = 1 if o.side == "buy" else -1
|
||||
balances[o.symbol] = balances.get(o.symbol, 0) + sign * int(o.filled)
|
||||
return [BalanceItem(symbol=sym, hldg_qty=qty)
|
||||
for sym, qty in balances.items() if qty != 0]
|
||||
|
||||
@@ -32,6 +32,7 @@ from trex_core.adapters.messaging.topology import (
|
||||
EVENTS_DLQ_QUEUE,
|
||||
EVENTS_EXCHANGE,
|
||||
INTENT_REQ_QUEUE,
|
||||
PAUSE_REQ_QUEUE,
|
||||
SNAPSHOT_REQ_QUEUE,
|
||||
STRATEGY_QUEUE_ARGS,
|
||||
)
|
||||
@@ -136,6 +137,11 @@ class RabbitMQTransport:
|
||||
durable=True,
|
||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
ch.queue_declare(
|
||||
PAUSE_REQ_QUEUE,
|
||||
durable=True,
|
||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||
)
|
||||
|
||||
def declare_strategy_queue(self, queue: str, binding_pattern: str) -> None:
|
||||
"""Per-strategy durable queue (REQ-023 AC-4) bound to events exchange."""
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
# durable for simplicity since the queue itself is shared) ---
|
||||
SNAPSHOT_REQ_QUEUE = "trex.snapshot.req"
|
||||
INTENT_REQ_QUEUE = "trex.intent.req"
|
||||
PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 control RPC
|
||||
|
||||
# --- Events ---
|
||||
EVENTS_EXCHANGE = "trex.events" # topic, durable
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- BAT-008 / REQ-032: durable manual pause overlay for snapshot read responses.
|
||||
-- pause unit = portfolio_id (= asset_id). Survives daemon restart and the
|
||||
-- daily snapshot refresh; only CLI / RPC control messages mutate it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshot_pause (
|
||||
portfolio_id TEXT PRIMARY KEY,
|
||||
actor TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
|
||||
);
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user