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:
co-authored by
genichin
parent
baae02d2af
commit
153a563f6d
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user