feat: persist unmanaged position lifecycle state

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-12 13:24:51 +00:00
committed by GitHub
co-authored by genichin
parent ae2e598209
commit fe51d211d9
12 changed files with 1274 additions and 5 deletions
+253 -1
View File
@@ -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]