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

931 lines
33 KiB
Python

"""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)
- durable `ReconcileWarningStore` slot_id -> reconcile-warning (Postgres; automatic,
set by reconcile B/C cases, cleared by sync/verify
— REQ-102, dcy-034)
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from enum import Enum
from typing import Protocol
BLOCKING_RECONCILE_CASES = frozenset({"B", "C"})
# ---------------------------------------------------------------- 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.
REQ-116 (BAT-055): strategy_name and markets added additively.
strategy_params already existed; now surfaced in slot_to_dict output.
"""
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: str | None = None
strategy_params: dict = field(default_factory=dict) # opaque, pass-through
# REQ-116 additive fields (BAT-055) — safe defaults for legacy hub responses
strategy_name: str | None = None # portfolio-hub strategy_name; None if absent
markets: list = field(default_factory=list) # portfolio-hub markets list; [] if absent
class PortfolioHubReadError(Exception):
"""Base class for portfolio-hub read failures."""
class PortfolioHubUnavailableError(PortfolioHubReadError):
"""portfolio-hub unreachable / 5xx / timeout — degraded signal upstream."""
# ---------------------------------------------------------------- realized PnL (REQ-109)
@dataclass(frozen=True)
class RealizedPnlItem:
"""Single sell-trade realized PnL item from portfolio-hub WAC response.
Maps to portfolio-hub RealizedPnlItem schema (REQ-040).
Minimum required: symbol, realized_pnl, sell_qty.
Additional fields preserved for completeness.
"""
symbol: str | None
realized_pnl: float
sell_qty: float
cost_basis_per_unit: float = 0.0
fee_amount: float = 0.0
tax_amount: float = 0.0
@dataclass(frozen=True)
class RealizedPnlResult:
"""Aggregated WAC realized PnL result for one portfolio (REQ-109).
Returned by get_realized_pnl port method.
"""
items: list[RealizedPnlItem]
cost_basis_from: str | None # ISO date string or None
data_completeness_warning: bool # True when portfolio-hub signals incomplete history
total_realized_pnl: float
# ---------------------------------------------------------------- broker credential (REQ-112)
@dataclass(frozen=True)
class BrokerCredentialPayload:
"""KIS broker credential fetched from portfolio-hub (REQ-112).
Carries app_key/app_secret/account_ref for KIS API authentication.
credential_version is optional (portfolio-hub versioning, informational only).
NFR-2: app_secret must never be logged in plaintext.
"""
account_ref: str
app_key: str
app_secret: str
credential_version: int | None = None
@dataclass(frozen=True)
class TradingContext:
"""Combined broker credential + trading_enabled flag from /v1/user_info (REQ-112).
Returned by fetch_broker_credential() in a single atomic read to avoid
double HTTP calls.
"""
broker: BrokerCredentialPayload | None
trading_enabled: bool
class PortfolioHubReadPort(Protocol):
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)
+ broker credential (REQ-112)."""
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).
"""
...
def get_realized_pnl(
self,
portfolio_id: str,
from_dt: str,
to_dt: str,
*,
symbol: str | None = None,
) -> RealizedPnlResult:
"""GET /v1/portfolios/{portfolio_id}/pnl/realized — WAC realized PnL (REQ-109).
Args:
portfolio_id: target portfolio.
from_dt: ISO date string (YYYY-MM-DD), sell-date inclusive start.
to_dt: ISO date string (YYYY-MM-DD), sell-date inclusive end.
symbol: optional symbol filter.
Returns:
RealizedPnlResult with per-sell items, total, cost_basis_from,
data_completeness_warning.
Raises:
PortfolioHubUnavailableError: on network error or non-2xx response.
"""
...
def fetch_broker_credential(self) -> TradingContext:
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
Returns a TradingContext with:
- broker: BrokerCredentialPayload if the daemon has assigned credentials,
None if broker is null (no credential assigned) or credential_payload
is null (decryption failure).
- trading_enabled: bool as reported by portfolio-hub.
Raises:
PortfolioHubUnavailableError: on network error, non-2xx (incl. 403),
or JSON parse failure.
"""
...
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: datetime | None = 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: DegradedReason | None = None
self._degraded_message: str | None = None
self._last_refresh_at: datetime | None = None
# --- universe ---
def swap(self, items: list[PortfolioItem]) -> None:
"""Replace the entire universe atomically (immutable swap, Decision 6)."""
now = datetime.now(tz=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) -> SnapshotAsset | None:
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=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[DegradedReason | None, str | None]:
with self._lock:
return self._degraded, self._degraded_message
def last_refresh_at(self) -> datetime | None:
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: set[str] | None = 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)
# ---------------------------------------------------------------- kill-switch
# Day-scoped exclusion overlay (REQ-044/046 / BAT-012). Distinct from pause:
# keyed by (portfolio_id, trading_date) so it auto-clears next trading day.
GLOBAL_KILLSWITCH = "*" # sentinel: whole-universe exclusion for the day
class KillSwitchStore(Protocol):
"""Day-scoped kill-switch exclusion overlay (REQ-044)."""
def list_excluded(self, trading_date) -> set[str]:
...
def exclude(self, portfolio_id: str, trading_date, *, actor: str = "kill-switch") -> None:
...
def clear(self, portfolio_id: str, trading_date) -> None:
...
class InMemoryKillSwitchStore:
"""Process-local day-scoped kill-switch store for tests + CI."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._excluded: set[tuple[str, object]] = set() # (portfolio_id, trading_date)
def list_excluded(self, trading_date) -> set[str]:
with self._lock:
return {pid for pid, d in self._excluded if d == trading_date}
def exclude(self, portfolio_id: str, trading_date, *, actor: str = "kill-switch") -> None:
with self._lock:
self._excluded.add((portfolio_id, trading_date))
def clear(self, portfolio_id: str, trading_date) -> None:
with self._lock:
self._excluded.discard((portfolio_id, trading_date))
# ---------------------------------------------------------------- reconcile-warning store (REQ-102)
# BAT-050 / dcy-034: durable reconcile-warning channel — distinct from pause (manual).
# Keyed by slot_id (TEXT UUID string). case_code: 'B' (slot↔broker qty mismatch)
# or 'C' (ledger↔broker mismatch). Survives restart (PostgreSQL-backed in production).
class ReconcileWarningStore(Protocol):
"""Durable reconcile-warning overlay keyed by (slot_id, case_code)."""
def set_warned(
self,
slot_id: str,
*,
portfolio_id: str,
case_code: str,
actor: str = "reconcile",
reason: str = "",
) -> None:
...
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
...
def is_warned(self, slot_id: str) -> bool:
...
def list_warned(self) -> set[str]:
...
def get_warning_info(self, slot_id: str) -> dict | None:
"""Return warning info dict or None.
Returned dict includes:
{"portfolio_id", "case_code", "case_codes", "actor", "reason"}
where case_code is a backward-compatible preferred case and case_codes is
the full active case set for the slot.
Used by verify to determine whether C-case is active (REQ-106 OQ-C).
Returns None when the slot has no active warning.
"""
...
def list_c_case_warned(self) -> set[str]:
"""Return set of slot_id strings that have an active case_code='C' warning.
AC-0023: used by strategy slots RPC to exclude C-case mismatched holding slots.
"""
...
class InMemoryReconcileWarningStore:
"""Process-local reconcile-warning store for tests + CI (REQ-102)."""
def __init__(self) -> None:
self._lock = threading.Lock()
# slot_id -> case_code -> {"portfolio_id", "actor", "reason"}
self._warned: dict[str, dict[str, dict]] = {}
def set_warned(
self,
slot_id: str,
*,
portfolio_id: str,
case_code: str,
actor: str = "reconcile",
reason: str = "",
) -> None:
with self._lock:
per_case = self._warned.setdefault(slot_id, {})
per_case[case_code] = {
"portfolio_id": portfolio_id,
"actor": actor,
"reason": reason,
}
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
with self._lock:
if case_code is None:
self._warned.pop(slot_id, None)
return
per_case = self._warned.get(slot_id)
if not per_case:
return
per_case.pop(case_code, None)
if not per_case:
self._warned.pop(slot_id, None)
def is_warned(self, slot_id: str) -> bool:
with self._lock:
return slot_id in self._warned
def list_warned(self) -> set[str]:
with self._lock:
return set(self._warned.keys())
def get_warning_info(self, slot_id: str) -> dict | None:
with self._lock:
per_case = self._warned.get(slot_id)
if not per_case:
return None
case_codes = sorted(per_case.keys())
preferred_case = "C" if "C" in per_case else case_codes[0]
preferred = per_case[preferred_case]
return {
"portfolio_id": preferred.get("portfolio_id", ""),
"case_code": preferred_case,
"case_codes": case_codes,
"actor": preferred.get("actor", ""),
"reason": preferred.get("reason", ""),
}
def list_c_case_warned(self) -> set[str]:
with self._lock:
return {sid for sid, per_case in self._warned.items() if "C" in per_case}
class ReconcileNotificationStore(Protocol):
"""Durable mismatch notification lifecycle store (req-0007, ac-0024).
Tracks the last successfully published fingerprint for each B/C-case identity
to suppress duplicate alarms across scheduler ticks and process restarts.
Identity keys:
B-case: slot_id (UUID string)
C-case: `"{instance_key}\\x00C\\x00{symbol}"` (null-separated to avoid collisions)
lifecycle_state values:
"notified" — detected or changed alarm was published for this fingerprint
"pending_resolved" — warning was cleared but resolved publish failed; retry pending
"""
def get_state(self, identity_key: str) -> dict | None:
"""Return state dict or None.
State dict keys: identity_key, case_code, instance_key, symbol,
quantity_a, quantity_b, lifecycle_state.
"""
...
def set_state(
self,
identity_key: str,
*,
case_code: str,
instance_key: str,
symbol: str,
quantity_a: int,
quantity_b: int,
lifecycle_state: str,
) -> None:
"""Upsert notification state (call after successful publish)."""
...
def clear_state(self, identity_key: str) -> None:
"""Remove state (call after successful resolved publish)."""
...
def list_states_by_lifecycle(self, lifecycle_state: str) -> list[dict]:
"""Return all state dicts with the given lifecycle_state."""
...
class InMemoryReconcileNotificationStore:
"""Process-local notification lifecycle store for tests + CI (req-0007)."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._states: dict[str, dict] = {}
def get_state(self, identity_key: str) -> dict | None:
with self._lock:
s = self._states.get(identity_key)
return dict(s) if s is not None else None
def set_state(
self,
identity_key: str,
*,
case_code: str,
instance_key: str,
symbol: str,
quantity_a: int,
quantity_b: int,
lifecycle_state: str,
) -> None:
with self._lock:
self._states[identity_key] = {
"identity_key": identity_key,
"case_code": case_code,
"instance_key": instance_key,
"symbol": symbol,
"quantity_a": quantity_a,
"quantity_b": quantity_b,
"lifecycle_state": lifecycle_state,
}
def clear_state(self, identity_key: str) -> None:
with self._lock:
self._states.pop(identity_key, None)
def list_states_by_lifecycle(self, lifecycle_state: str) -> list[dict]:
with self._lock:
return [
dict(s) for s in self._states.values()
if s["lifecycle_state"] == lifecycle_state
]
class UnmanagedPositionStore(Protocol):
"""Durable unmanaged-position lifecycle store keyed by (instance_key, symbol)."""
def observe(
self,
*,
instance_key: str,
symbol: str,
quantity: int,
observed_at: datetime | None = None,
actor: str = "reconcile",
) -> dict:
"""Return {"transition", "previous_quantity", "state"} for one observation."""
...
def resolve_absent(
self,
*,
instance_key: str,
observed_symbols: set[str],
observed_at: datetime | None = None,
actor: str = "reconcile",
) -> list[dict]:
"""Mark active positions absent from a successful broker read as resolved."""
...
def get_state(self, *, instance_key: str, symbol: str) -> dict | None:
...
def list_states(
self,
*,
instance_key: str,
lifecycle_state: str = "active",
symbol: str | None = None,
) -> list[dict]:
...
def record_operator_action(
self,
*,
instance_key: str,
symbol: str,
action: str,
evidence_ref: str,
actor: str = "operator",
note: str = "",
) -> dict | None:
"""Persist an explicit operator disposition and append an audit event."""
...
def list_audit(self, *, instance_key: str, symbol: str) -> list[dict]:
...
class InMemoryUnmanagedPositionStore:
"""Process-local unmanaged-position lifecycle store for tests + CI."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._states: dict[tuple[str, str], dict] = {}
self._audit: list[dict] = []
def observe(
self,
*,
instance_key: str,
symbol: str,
quantity: int,
observed_at: datetime | None = None,
actor: str = "reconcile",
) -> dict:
now = observed_at or datetime.now(tz=UTC)
key = (instance_key, symbol)
with self._lock:
existing = self._states.get(key)
previous_quantity = existing.get("quantity") if existing is not None else None
preserved = {} if existing is None else {
"created_at": existing.get("created_at", now),
}
if existing is None or existing.get("lifecycle_state") == "resolved":
transition = "detected"
state = {
"instance_key": instance_key,
"symbol": symbol,
"quantity": quantity,
"lifecycle_state": "active",
"first_seen_at": (
existing.get("first_seen_at", now) if existing is not None else now
),
"last_seen_at": now,
"quantity_changed_at": now,
"resolved_at": None,
"operator_action": None,
"evidence_ref": None,
"operator_note": "",
"operator_actor": None,
"operator_updated_at": None,
"recorded_at": now,
**preserved,
"updated_at": now,
}
self._states[key] = state
elif int(existing["quantity"]) != int(quantity):
transition = "quantity_changed"
existing["quantity"] = quantity
existing["last_seen_at"] = now
existing["quantity_changed_at"] = now
existing["resolved_at"] = None
existing["lifecycle_state"] = "active"
existing["updated_at"] = now
state = existing
else:
transition = "unchanged"
existing["last_seen_at"] = now
existing["updated_at"] = now
state = existing
if transition != "unchanged":
self._audit.append(
{
"instance_key": instance_key,
"symbol": symbol,
"event_type": transition,
"action": None,
"quantity": quantity,
"lifecycle_state": state["lifecycle_state"],
"actor": actor,
"evidence_ref": state.get("evidence_ref"),
"note": (
f"previous_quantity={previous_quantity}"
if transition == "quantity_changed" and previous_quantity is not None
else ""
),
"recorded_at": now,
}
)
return {
"transition": transition,
"previous_quantity": previous_quantity,
"state": dict(state),
}
def resolve_absent(
self,
*,
instance_key: str,
observed_symbols: set[str],
observed_at: datetime | None = None,
actor: str = "reconcile",
) -> list[dict]:
now = observed_at or datetime.now(tz=UTC)
with self._lock:
resolved: list[dict] = []
for key, state in sorted(self._states.items()):
if key[0] != instance_key:
continue
if state.get("lifecycle_state") != "active":
continue
if state["symbol"] in observed_symbols:
continue
state["lifecycle_state"] = "resolved"
state["resolved_at"] = now
state["updated_at"] = now
resolved.append(dict(state))
self._audit.append(
{
"instance_key": state["instance_key"],
"symbol": state["symbol"],
"event_type": "resolved",
"action": None,
"quantity": state["quantity"],
"lifecycle_state": "resolved",
"actor": actor,
"evidence_ref": state.get("evidence_ref"),
"note": "",
"recorded_at": now,
}
)
return resolved
def get_state(self, *, instance_key: str, symbol: str) -> dict | None:
with self._lock:
state = self._states.get((instance_key, symbol))
return dict(state) if state is not None else None
def list_states(
self,
*,
instance_key: str,
lifecycle_state: str = "active",
symbol: str | None = None,
) -> list[dict]:
with self._lock:
rows = []
for state in self._states.values():
if state["instance_key"] != instance_key:
continue
if lifecycle_state != "all" and state["lifecycle_state"] != lifecycle_state:
continue
if symbol is not None and state["symbol"] != symbol:
continue
rows.append(dict(state))
return sorted(rows, key=lambda item: item["symbol"])
def record_operator_action(
self,
*,
instance_key: str,
symbol: str,
action: str,
evidence_ref: str,
actor: str = "operator",
note: str = "",
) -> dict | None:
now = datetime.now(tz=UTC)
with self._lock:
state = self._states.get((instance_key, symbol))
if state is None:
return None
state["operator_action"] = action
state["evidence_ref"] = evidence_ref
state["operator_note"] = note
state["operator_actor"] = actor
state["operator_updated_at"] = now
state["updated_at"] = now
self._audit.append(
{
"instance_key": instance_key,
"symbol": symbol,
"event_type": "operator_action",
"action": action,
"quantity": state["quantity"],
"lifecycle_state": state["lifecycle_state"],
"actor": actor,
"evidence_ref": evidence_ref,
"note": note,
"recorded_at": now,
}
)
return dict(state)
def list_audit(self, *, instance_key: str, symbol: str) -> list[dict]:
with self._lock:
return [
dict(row)
for row in self._audit
if row["instance_key"] == instance_key and row["symbol"] == symbol
]
def slot_to_dict(
s,
*,
snapshot_store: SnapshotStore | None = None,
watchdog_store=None,
pause_store: PauseStore | None = None,
warn_store: ReconcileWarningStore | None = None,
warning_cases_by_slot: dict[str, set[str]] | None = None,
watchdog_timeout_sec: int = 120,
) -> dict:
"""Merged slot row for trex.slots.req (REQ-073/074).
Carries slot/core state + hub asset data + watchdog + overlay flags.
Field-level authority: slot/core = slot_id/status/qty/avg_price/entry_price/
forced_close_at; hub = asset_quantity/cash_balance; watchdog = watchdog{};
overlay = paused/warned/reconcile_warned. strategy fields excluded (REQ-074 AC-5).
"""
d = {
"slot_id": str(s.slot_id),
"status": s.status.value,
"symbol": s.symbol,
"qty": float(s.qty) if s.qty is not None else None,
"avg_price": float(s.avg_price) if s.avg_price is not None else None,
"entry_price": float(s.limit_price) if s.limit_price is not None else None,
"portfolio_id": s.portfolio_id,
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
"universe_detached": getattr(s, "retired_at", None) is not None,
# dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy
# Slot occupancy remains domain state; strategy-state writes do not use it.
"occupancy_seq": getattr(s, "occupancy_seq", 0),
}
# hub authority: asset_quantity (hub primary) + cash_balance
# REQ-116: strategy_name / strategy_params / markets also from PortfolioItem
asset_qty: float | None = None
cash_balance: float | None = None
strategy_name_val: str | None = None
strategy_params_val: dict = {}
markets_val: list = []
if snapshot_store is not None and s.portfolio_id:
snap = snapshot_store.get(s.portfolio_id)
if snap is not None:
asset_qty = float(snap.item.asset_quantity)
cash_balance = float(snap.item.cash_balance)
# REQ-116 (BAT-055): strategy fields from PortfolioItem (hub authority)
strategy_name_val = snap.item.strategy_name
strategy_params_val = dict(snap.item.strategy_params)
markets_val = list(snap.item.markets)
# qty (slot mirror) vs asset_quantity (hub): divergence → warned=True
slot_qty = float(s.qty) if s.qty is not None else None
warned = bool(slot_qty != asset_qty) if (slot_qty is not None and asset_qty is not None) else False
d["asset_quantity"] = asset_qty
d["cash_balance"] = cash_balance
d["cash_estimated"] = False # handler overlays ledger_outbox estimate if available
d["warned"] = warned
# REQ-116 (BAT-055): strategy fields — additive, safe default when hub absent
d["strategy_name"] = strategy_name_val
d["strategy_params"] = strategy_params_val
d["markets"] = markets_val
# watchdog nested object (WatchdogStore)
if watchdog_store is not None:
sid = str(s.slot_id)
last = watchdog_store.last_seen(sid)
d["watchdog"] = {
"expired": watchdog_store.is_expired(sid, watchdog_timeout_sec),
"last_seen": last.isoformat() if last else None,
}
else:
d["watchdog"] = None
# paused overlay (PauseStore) — orthogonal to status
paused = False
if pause_store is not None and s.portfolio_id:
paused = s.portfolio_id in pause_store.list_paused()
d["paused"] = paused
# BAT-050 / REQ-104: reconcile_warned overlay (ReconcileWarningStore) — additive field.
# Bulk list_warned() called once per RPC (caller pre-fetches; see make_slots_rpc_handler).
# warn_store=None → reconcile_warned=False (backward compatible, NFR-4).
warning_cases: set[str] = set()
if warning_cases_by_slot is not None:
warning_cases = set(warning_cases_by_slot.get(str(s.slot_id), set()))
elif warn_store is not None:
info = warn_store.get_warning_info(str(s.slot_id))
if info is not None:
raw_cases = info.get("case_codes")
if isinstance(raw_cases, list):
warning_cases = {str(c) for c in raw_cases if c}
else:
case_code = str(info.get("case_code", ""))
if case_code:
warning_cases = {case_code}
reconcile_warned = bool(warning_cases)
d["reconcile_warned"] = reconcile_warned
d["reconcile_warning_cases"] = sorted(warning_cases)
known_blocking = bool(warning_cases & BLOCKING_RECONCILE_CASES)
unknown_cases = bool(warning_cases - BLOCKING_RECONCILE_CASES)
d["trade_eligible"] = not (known_blocking or unknown_cases)
return d
def _sanitize(message: str) -> str:
"""Strip credentials/URLs/tokens from degraded messages."""
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
return out[:200]