feat: persist unmanaged position lifecycle state
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
ae2e598209
commit
fe51d211d9
@@ -537,6 +537,259 @@ class InMemoryReconcileNotificationStore:
|
||||
]
|
||||
|
||||
|
||||
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 {
|
||||
"operator_action": existing.get("operator_action"),
|
||||
"evidence_ref": existing.get("evidence_ref"),
|
||||
"operator_note": existing.get("operator_note"),
|
||||
"operator_actor": existing.get("operator_actor"),
|
||||
"operator_updated_at": existing.get("operator_updated_at"),
|
||||
"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": 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,
|
||||
"created_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 ""
|
||||
),
|
||||
"created_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": "",
|
||||
"created_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,
|
||||
"created_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,
|
||||
*,
|
||||
@@ -637,4 +890,3 @@ def _sanitize(message: str) -> str:
|
||||
out = out[:idx] + "<redacted>"
|
||||
break
|
||||
return out[:200]
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ from typing import cast
|
||||
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import BrokerPort
|
||||
from trex_core.application.portfolio import ReconcileWarningStore, SnapshotStore
|
||||
from trex_core.application.portfolio import (
|
||||
ReconcileWarningStore,
|
||||
SnapshotStore,
|
||||
UnmanagedPositionStore,
|
||||
)
|
||||
from trex_core.domain.slot.state import SlotState
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -341,6 +345,25 @@ def _retry_pending_resolved(
|
||||
)
|
||||
|
||||
|
||||
def _notify_unmanaged_position_transition(
|
||||
alarm_emitter: AlarmEmitter,
|
||||
*,
|
||||
event_type: str,
|
||||
instance_key: str,
|
||||
symbol: str,
|
||||
quantity: int,
|
||||
) -> None:
|
||||
alarm_emitter.publish_alarm(
|
||||
f"unmanaged_position_{event_type}",
|
||||
f"unmanaged position {event_type}: symbol={symbol}",
|
||||
fields={
|
||||
"instance_key": instance_key,
|
||||
"symbol": symbol,
|
||||
"broker_quantity": quantity,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def reconcile_core_holdings(
|
||||
*,
|
||||
slot_repo,
|
||||
@@ -350,6 +373,7 @@ def reconcile_core_holdings(
|
||||
alarm_emitter: AlarmEmitter | None = None,
|
||||
warn_store: ReconcileWarningStore | None = None,
|
||||
notification_store=None,
|
||||
unmanaged_position_store: UnmanagedPositionStore | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""dcy-020 / REQ-062 (BAT-029) + REQ-103 (BAT-050): surface core-slot holding
|
||||
vs broker divergence and set durable reconcile-warning for B case.
|
||||
@@ -377,6 +401,7 @@ def reconcile_core_holdings(
|
||||
decisions: dict[str, str] = {}
|
||||
matched: set[str] = set()
|
||||
active_b_identity_keys: set[str] = set() # req-0007: track active B mismatches for retry
|
||||
active_unmanaged_symbols: set[str] = set()
|
||||
for slot in slot_repo.list_holding_slots(instance_key):
|
||||
sym = slot.symbol
|
||||
matched.add(sym)
|
||||
@@ -458,10 +483,78 @@ def reconcile_core_holdings(
|
||||
)
|
||||
else:
|
||||
decisions[f"broker:{sym}"] = "unmanaged_position"
|
||||
_log.warning(
|
||||
"unmanaged position: symbol=%s qty=%d instance_key=%s",
|
||||
sym, bq, instance_key,
|
||||
active_unmanaged_symbols.add(sym)
|
||||
if unmanaged_position_store is None:
|
||||
_log.warning(
|
||||
"unmanaged position: symbol=%s qty=%d instance_key=%s",
|
||||
sym, bq, instance_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
observed = unmanaged_position_store.observe(
|
||||
instance_key=instance_key,
|
||||
symbol=sym,
|
||||
quantity=bq,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception(
|
||||
"failed to persist unmanaged_position for symbol=%s instance=%s",
|
||||
sym,
|
||||
instance_key,
|
||||
)
|
||||
_log.warning(
|
||||
"unmanaged position: symbol=%s qty=%d instance_key=%s",
|
||||
sym, bq, instance_key,
|
||||
)
|
||||
else:
|
||||
transition = observed["transition"]
|
||||
if transition == "detected":
|
||||
_log.warning(
|
||||
"unmanaged position detected: symbol=%s qty=%d instance_key=%s",
|
||||
sym, bq, instance_key,
|
||||
)
|
||||
if alarm_emitter is not None:
|
||||
_notify_unmanaged_position_transition(
|
||||
alarm_emitter,
|
||||
event_type="detected",
|
||||
instance_key=instance_key,
|
||||
symbol=sym,
|
||||
quantity=bq,
|
||||
)
|
||||
elif transition == "quantity_changed":
|
||||
_log.warning(
|
||||
"unmanaged position quantity changed: symbol=%s "
|
||||
"previous_qty=%s qty=%d instance_key=%s",
|
||||
sym, observed.get("previous_quantity"), bq, instance_key,
|
||||
)
|
||||
if alarm_emitter is not None:
|
||||
_notify_unmanaged_position_transition(
|
||||
alarm_emitter,
|
||||
event_type="changed",
|
||||
instance_key=instance_key,
|
||||
symbol=sym,
|
||||
quantity=bq,
|
||||
)
|
||||
if unmanaged_position_store is not None:
|
||||
try:
|
||||
for state in unmanaged_position_store.resolve_absent(
|
||||
instance_key=instance_key,
|
||||
observed_symbols=active_unmanaged_symbols,
|
||||
):
|
||||
_log.info(
|
||||
"unmanaged position resolved: symbol=%s qty=%d instance_key=%s",
|
||||
state["symbol"], state["quantity"], instance_key,
|
||||
)
|
||||
if alarm_emitter is not None:
|
||||
_notify_unmanaged_position_transition(
|
||||
alarm_emitter,
|
||||
event_type="resolved",
|
||||
instance_key=instance_key,
|
||||
symbol=state["symbol"],
|
||||
quantity=state["quantity"],
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("reconcile_core_holdings: unmanaged_position resolve failed")
|
||||
# req-0007 / ac-0024: retry pending_resolved for B-case identities no longer mismatched.
|
||||
if alarm_emitter is not None and notification_store is not None:
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,7 @@ from trex_core.application.portfolio import (
|
||||
PortfolioHubReadPort,
|
||||
PortfolioHubUnavailableError,
|
||||
ReconcileNotificationStore,
|
||||
UnmanagedPositionStore,
|
||||
ReconcileWarningStore,
|
||||
SnapshotStore,
|
||||
TradingContext,
|
||||
@@ -51,6 +52,7 @@ class SnapshotScheduler:
|
||||
alarm_emitter: Optional[AlarmEmitter] = None,
|
||||
warn_store: Optional[ReconcileWarningStore] = None,
|
||||
notification_store: Optional[ReconcileNotificationStore] = None,
|
||||
unmanaged_position_store: Optional[UnmanagedPositionStore] = None,
|
||||
credential_fetch_enabled: bool = False,
|
||||
kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None,
|
||||
credential_refresh: Optional[Callable[[], object]] = None,
|
||||
@@ -75,6 +77,7 @@ class SnapshotScheduler:
|
||||
self._warn_store = warn_store
|
||||
# req-0007 / ac-0024: durable notification deduplication store (Optional).
|
||||
self._notification_store = notification_store
|
||||
self._unmanaged_position_store = unmanaged_position_store
|
||||
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
|
||||
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
|
||||
self._credential_fetch_enabled: bool = credential_fetch_enabled
|
||||
@@ -336,6 +339,7 @@ class SnapshotScheduler:
|
||||
alarm_emitter=self._alarm_emitter, # dcy-033 / REQ-100
|
||||
warn_store=self._warn_store, # BAT-050 / REQ-103
|
||||
notification_store=self._notification_store, # req-0007 / ac-0024
|
||||
unmanaged_position_store=self._unmanaged_position_store,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("core-holding divergence reconcile failed")
|
||||
|
||||
Reference in New Issue
Block a user