Files
trex-core/trex_core/application/portfolio.py
T

362 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Portfolio snapshot read + selection (REQ-027/028/029/030/031, dcy-005).
Application-layer port for reading portfolio-hub universe + in-process
snapshot store + asset-selection service. Domain stays free of HTTP and
RabbitMQ deps (NFR-1).
The snapshot is split between:
- in-memory `SnapshotStore` universe + cash/position + warnings (process-local;
warning is automatic/temporary, reset on restart)
- durable `PauseStore` portfolio_id -> paused (Postgres; manual/persistent,
survives restart and daily refresh — REQ-032)
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Protocol
# ---------------------------------------------------------------- portfolio-hub
@dataclass(frozen=True)
class PortfolioItem:
"""One asset in the daemon's portfolio universe (REQ-027 AC-1).
Mirrors portfolio-hub's `list_portfolios_v1` PortfolioItem with **no price
field** — pricing is the strategy module's concern, not core's.
"""
portfolio_id: str # == asset_id, dcy-004 OQ-1
symbol: str
cash_balance: float
asset_quantity: float # integer shares semantically; float for tolerance
active_strategy_id: Optional[str] = None
strategy_params: dict = field(default_factory=dict) # opaque, pass-through
class PortfolioHubReadError(Exception):
"""Base class for portfolio-hub read failures."""
class PortfolioHubUnavailableError(PortfolioHubReadError):
"""portfolio-hub unreachable / 5xx / timeout — degraded signal upstream."""
class PortfolioHubReadPort(Protocol):
"""Read-side contract for portfolio-hub universe (REQ-027)."""
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
...
def whoami(self) -> str:
"""Resolve daemon_id from the credentials/token (REQ-027 AC-4).
Called once at startup so `daemon_id` lookup never depends on
`TREX_DAEMON_ID` env (single source of truth = auth token).
"""
...
# ---------------------------------------------------------------- selection enums
class View(str, Enum):
"""Snapshot view modes (REQ-029, dcy-005 OQ-11/OQ-12)."""
ALL = "all" # universe ∪ holding slots
BUYABLE = "buyable" # universe − occupied slots
HELD = "held" # holding slots only
class DegradedReason(str, Enum):
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""
PORTFOLIO_HUB_UNAVAILABLE = "portfolio_hub_unavailable"
SNAPSHOT_FETCH_FAILED = "snapshot_fetch_failed"
CASH_POSITION_STALE = "cash_position_stale"
# ---------------------------------------------------------------- snapshot store
@dataclass(frozen=True)
class SnapshotAsset:
"""A portfolio item augmented with overlays (warning, sequence)."""
item: PortfolioItem
warning: bool = False
updated_at: Optional[datetime] = None
class SnapshotStore:
"""In-memory snapshot — universe baseline + warning overlay.
Two-phase: callers `swap(new_universe)` to atomically replace the universe
after a fetch, and `set_warning(portfolio_id, on)` to toggle the automatic
warning overlay (REQ-031). pause overlay lives in PauseStore (REQ-032).
Thread-safe; the snapshot RPC handler can read concurrently with the
scheduler refresh.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._assets: dict[str, SnapshotAsset] = {}
self._degraded: Optional[DegradedReason] = None
self._degraded_message: Optional[str] = None
self._last_refresh_at: Optional[datetime] = None
# --- universe ---
def swap(self, items: list[PortfolioItem]) -> None:
"""Replace the entire universe atomically (immutable swap, Decision 6)."""
now = datetime.now(tz=timezone.utc)
with self._lock:
prior_warnings = {pid for pid, a in self._assets.items() if a.warning}
new_assets: dict[str, SnapshotAsset] = {}
for item in items:
# Carry forward warning if the same portfolio was warned before;
# reconciliation will clear it on the next successful check.
warn = item.portfolio_id in prior_warnings
new_assets[item.portfolio_id] = SnapshotAsset(
item=item, warning=warn, updated_at=now,
)
self._assets = new_assets
self._degraded = None
self._degraded_message = None
self._last_refresh_at = now
def list_assets(self) -> list[SnapshotAsset]:
with self._lock:
return list(self._assets.values())
def get(self, portfolio_id: str) -> Optional[SnapshotAsset]:
with self._lock:
return self._assets.get(portfolio_id)
# --- warning overlay (automatic, in-memory only) ---
def set_warning(self, portfolio_id: str, on: bool) -> None:
with self._lock:
a = self._assets.get(portfolio_id)
if a is None or a.warning is on:
return
self._assets[portfolio_id] = replace(a, warning=on)
def warnings(self) -> set[str]:
with self._lock:
return {pid for pid, a in self._assets.items() if a.warning}
# --- option D cash/position (REQ-030 AC-2/AC-3 — wired but no-op until
# portfolio-hub#8 ships) ---
def update_cash_position(
self,
portfolio_id: str,
cash_balance: float,
asset_quantity: float,
) -> None:
with self._lock:
a = self._assets.get(portfolio_id)
if a is None:
return
self._assets[portfolio_id] = replace(
a, item=replace(a.item, cash_balance=cash_balance,
asset_quantity=asset_quantity),
updated_at=datetime.now(tz=timezone.utc),
)
# --- degraded signal (REQ-028 AC-2) ---
def set_degraded(self, reason: DegradedReason, message: str) -> None:
with self._lock:
self._degraded = reason
self._degraded_message = _sanitize(message)
def clear_degraded(self) -> None:
with self._lock:
self._degraded = None
self._degraded_message = None
def degraded_state(self) -> tuple[Optional[DegradedReason], Optional[str]]:
with self._lock:
return self._degraded, self._degraded_message
def last_refresh_at(self) -> Optional[datetime]:
with self._lock:
return self._last_refresh_at
# ---------------------------------------------------------------- pause store
class PauseStore(Protocol):
"""Durable pause overlay — `portfolio_id` PK (REQ-032)."""
def list_paused(self) -> set[str]:
...
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
...
def resume(self, portfolio_id: str) -> None:
...
class InMemoryPauseStore:
"""Process-local pause store for tests + CI."""
def __init__(self, initial: Optional[set[str]] = None) -> None:
self._lock = threading.Lock()
self._paused: set[str] = set(initial or set())
def list_paused(self) -> set[str]:
with self._lock:
return set(self._paused)
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
with self._lock:
self._paused.add(portfolio_id)
def resume(self, portfolio_id: str) -> None:
with self._lock:
self._paused.discard(portfolio_id)
# ---------------------------------------------------------------- selection
@dataclass(frozen=True)
class SnapshotEnvelope:
"""REQ-028 response envelope (transport-agnostic)."""
assets: list[dict]
view: str
available_slots: int
degraded: bool
reason: Optional[str]
message: Optional[str] = None
def to_dict(self) -> dict:
return {
"assets": list(self.assets),
"view": self.view,
"available_slots": self.available_slots,
"degraded": self.degraded,
"reason": self.reason,
"message": self.message,
}
def _asset_to_dict(item: PortfolioItem) -> dict:
"""REQ-027 AC-1 / REQ-028 AC-3 — strict no-price field shape."""
return {
"symbol": item.symbol,
"asset_id": item.portfolio_id,
"cash_balance": item.cash_balance,
"asset_quantity": item.asset_quantity,
"active_strategy_id": item.active_strategy_id,
"strategy_params": dict(item.strategy_params or {}),
}
def _sanitize(message: str) -> str:
"""Strip credentials/URLs/tokens — REQ-028 AC-2 sanitization."""
if not message:
return ""
out = message
for needle in ("authorization:", "Bearer ", "token=", "appsecret="):
idx = out.lower().find(needle.lower())
if idx >= 0:
out = out[:idx] + "<redacted>"
break
# Truncate to keep response payload bounded.
return out[:200]
def build_snapshot_envelope(
*,
store: SnapshotStore,
pause_store: PauseStore,
slot_repo,
available_slots: int,
view: View = View.ALL,
strategy_key: Optional[str] = None,
trading_date=None,
) -> SnapshotEnvelope:
"""Compose base(view) − mask(pending·warning·pause) − strategy_key (REQ-029).
`slot_repo` is the SlotRepository used to read holding/pending state.
`trading_date` defaults to UTC today (matches the daemon's preallocate).
"""
# Read core authoritative slot state once.
day = trading_date or datetime.now(tz=timezone.utc).date()
slots = slot_repo.list_slots(day)
holding_pids: set[str] = set()
pending_pids: set[str] = set()
holding_qty: dict[str, float] = {}
for s in slots:
if s.portfolio_id is None:
continue
if s.status.value == "holding":
holding_pids.add(s.portfolio_id)
holding_qty[s.portfolio_id] = float(s.qty or 0.0)
elif s.status.value in ("pending_buy", "pending_sell"):
pending_pids.add(s.portfolio_id)
assets = store.list_assets()
paused = pause_store.list_paused()
warned = {a.item.portfolio_id for a in assets if a.warning}
# Build the base set (view).
by_pid = {a.item.portfolio_id: a for a in assets}
base_pids: list[str]
if view is View.HELD:
base_pids = sorted(holding_pids)
elif view is View.BUYABLE:
if available_slots <= 0:
base_pids = []
else:
# universe − occupied slots (pending OR holding occupy)
occupied = holding_pids | pending_pids
base_pids = [a.item.portfolio_id for a in assets
if a.item.portfolio_id not in occupied]
else: # ALL
base_pids = list({a.item.portfolio_id for a in assets} | holding_pids)
base_pids.sort()
# Apply masks: pending OR warning OR pause.
masked = pending_pids | warned | paused
# Filter and shape.
out: list[dict] = []
for pid in base_pids:
if pid in masked:
continue
if pid in by_pid:
item = by_pid[pid].item
elif pid in holding_pids:
# holding slot for an asset NOT in the current universe (e.g. fetch
# gap) — synthesize a minimal item using slot data so the response
# still carries authoritative position info (REQ-029 slot overlay
# priority over portfolio-hub presence).
slot = next((s for s in slots if s.portfolio_id == pid), None)
item = PortfolioItem(
portfolio_id=pid,
symbol=(slot.symbol if slot else "") or "",
cash_balance=0.0,
asset_quantity=float((slot.qty if slot else 0.0) or 0.0),
active_strategy_id=(slot.strategy_id if slot else None),
strategy_params={},
)
else:
continue
if strategy_key is not None and item.active_strategy_id != strategy_key:
continue
d = _asset_to_dict(item)
# holding slot quantity wins for held assets (slot-overlay priority).
if pid in holding_qty:
d["asset_quantity"] = holding_qty[pid]
out.append(d)
reason_code, message = store.degraded_state()
return SnapshotEnvelope(
assets=out,
view=view.value,
available_slots=max(0, int(available_slots)),
degraded=reason_code is not None,
reason=reason_code.value if reason_code is not None else None,
message=message,
)