feat(alerts): publish reconciliation quantity mismatches to trex-notifier (req-0007/ac-0024)

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-25 14:50:29 +00:00
committed by GitHub
co-authored by genichin
parent baae02d2af
commit 153a563f6d
6 changed files with 1458 additions and 52 deletions
+125 -34
View File
@@ -17,10 +17,9 @@ from __future__ import annotations
import threading
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from datetime import UTC, datetime
from enum import Enum
from typing import Optional, Protocol
from typing import Protocol
# ---------------------------------------------------------------- portfolio-hub
@@ -38,10 +37,10 @@ class PortfolioItem:
symbol: str
cash_balance: float
asset_quantity: float # integer shares semantically; float for tolerance
active_strategy_id: Optional[str] = None
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: Optional[str] = None # portfolio-hub strategy_name; None if absent
strategy_name: str | None = None # portfolio-hub strategy_name; None if absent
markets: list = field(default_factory=list) # portfolio-hub markets list; [] if absent
@@ -63,7 +62,7 @@ class RealizedPnlItem:
Minimum required: symbol, realized_pnl, sell_qty.
Additional fields preserved for completeness.
"""
symbol: Optional[str]
symbol: str | None
realized_pnl: float
sell_qty: float
cost_basis_per_unit: float = 0.0
@@ -78,7 +77,7 @@ class RealizedPnlResult:
Returned by get_realized_pnl port method.
"""
items: list[RealizedPnlItem]
cost_basis_from: Optional[str] # ISO date string or None
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
@@ -96,7 +95,7 @@ class BrokerCredentialPayload:
account_ref: str
app_key: str
app_secret: str
credential_version: Optional[int] = None
credential_version: int | None = None
@dataclass(frozen=True)
@@ -106,7 +105,7 @@ class TradingContext:
Returned by fetch_broker_credential() in a single atomic read to avoid
double HTTP calls.
"""
broker: Optional[BrokerCredentialPayload]
broker: BrokerCredentialPayload | None
trading_enabled: bool
@@ -132,8 +131,8 @@ class PortfolioHubReadPort(Protocol):
from_dt: str,
to_dt: str,
*,
symbol: Optional[str] = None,
) -> "RealizedPnlResult":
symbol: str | None = None,
) -> RealizedPnlResult:
"""GET /v1/portfolios/{portfolio_id}/pnl/realized — WAC realized PnL (REQ-109).
Args:
@@ -151,7 +150,7 @@ class PortfolioHubReadPort(Protocol):
"""
...
def fetch_broker_credential(self) -> "TradingContext":
def fetch_broker_credential(self) -> TradingContext:
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
Returns a TradingContext with:
@@ -181,7 +180,7 @@ class SnapshotAsset:
"""A portfolio item augmented with overlays (warning, sequence)."""
item: PortfolioItem
warning: bool = False
updated_at: Optional[datetime] = None
updated_at: datetime | None = None
class SnapshotStore:
@@ -198,14 +197,14 @@ class SnapshotStore:
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
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=timezone.utc)
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] = {}
@@ -225,7 +224,7 @@ class SnapshotStore:
with self._lock:
return list(self._assets.values())
def get(self, portfolio_id: str) -> Optional[SnapshotAsset]:
def get(self, portfolio_id: str) -> SnapshotAsset | None:
with self._lock:
return self._assets.get(portfolio_id)
@@ -256,7 +255,7 @@ class SnapshotStore:
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),
updated_at=datetime.now(tz=UTC),
)
# --- degraded signal (REQ-028 AC-2) ---
@@ -270,11 +269,11 @@ class SnapshotStore:
self._degraded = None
self._degraded_message = None
def degraded_state(self) -> tuple[Optional[DegradedReason], Optional[str]]:
def degraded_state(self) -> tuple[DegradedReason | None, str | None]:
with self._lock:
return self._degraded, self._degraded_message
def last_refresh_at(self) -> Optional[datetime]:
def last_refresh_at(self) -> datetime | None:
with self._lock:
return self._last_refresh_at
@@ -297,7 +296,7 @@ class PauseStore(Protocol):
class InMemoryPauseStore:
"""Process-local pause store for tests + CI."""
def __init__(self, initial: Optional[set[str]] = None) -> None:
def __init__(self, initial: set[str] | None = None) -> None:
self._lock = threading.Lock()
self._paused: set[str] = set(initial or set())
@@ -379,10 +378,10 @@ class ReconcileWarningStore(Protocol):
def is_warned(self, slot_id: str) -> bool:
...
def list_warned(self) -> "set[str]":
def list_warned(self) -> set[str]:
...
def get_warning_info(self, slot_id: str) -> "Optional[dict]":
def get_warning_info(self, slot_id: str) -> dict | None:
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None.
Used by verify to determine whether the warning is case_code='C'
@@ -391,7 +390,7 @@ class ReconcileWarningStore(Protocol):
"""
...
def list_c_case_warned(self) -> "set[str]":
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.
@@ -432,27 +431,119 @@ class InMemoryReconcileWarningStore:
with self._lock:
return slot_id in self._warned
def list_warned(self) -> "set[str]":
def list_warned(self) -> set[str]:
with self._lock:
return set(self._warned.keys())
def get_warning_info(self, slot_id: str) -> "Optional[dict]":
def get_warning_info(self, slot_id: str) -> dict | None:
with self._lock:
info = self._warned.get(slot_id)
return dict(info) if info is not None else None
def list_c_case_warned(self) -> "set[str]":
def list_c_case_warned(self) -> set[str]:
with self._lock:
return {sid for sid, info in self._warned.items() if info.get("case_code") == "C"}
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
]
def slot_to_dict(
s,
*,
snapshot_store: "Optional[SnapshotStore]" = None,
snapshot_store: SnapshotStore | None = None,
watchdog_store=None,
pause_store: "Optional[PauseStore]" = None,
warn_store: "Optional[ReconcileWarningStore]" = None,
pause_store: PauseStore | None = None,
warn_store: ReconcileWarningStore | None = None,
watchdog_timeout_sec: int = 120,
) -> dict:
"""Merged slot row for trex.slots.req (REQ-073/074).
@@ -479,9 +570,9 @@ def slot_to_dict(
# hub authority: asset_quantity (hub primary) + cash_balance
# REQ-116: strategy_name / strategy_params / markets also from PortfolioItem
asset_qty: Optional[float] = None
cash_balance: Optional[float] = None
strategy_name_val: Optional[str] = None
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: