"""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] + "" break return out[:200]