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
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS unmanaged_position_state (
instance_key TEXT NOT NULL,
symbol TEXT NOT NULL,
quantity INTEGER NOT NULL,
lifecycle_state TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
quantity_changed_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ NULL,
operator_action TEXT NULL,
evidence_ref TEXT NULL,
operator_note TEXT NOT NULL DEFAULT '',
operator_actor TEXT NULL,
operator_updated_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (instance_key, symbol)
);
CREATE INDEX IF NOT EXISTS idx_unmanaged_position_state_lifecycle
ON unmanaged_position_state (instance_key, lifecycle_state, updated_at DESC);
CREATE TABLE IF NOT EXISTS unmanaged_position_audit (
audit_id BIGSERIAL PRIMARY KEY,
instance_key TEXT NOT NULL,
symbol TEXT NOT NULL,
event_type TEXT NOT NULL,
action TEXT NULL,
quantity INTEGER NOT NULL,
lifecycle_state TEXT NOT NULL,
actor TEXT NOT NULL,
evidence_ref TEXT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_unmanaged_position_audit_lookup
ON unmanaged_position_audit (instance_key, symbol, audit_id);
+2
View File
@@ -3,10 +3,12 @@ from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
from trex_core.adapters.portfolio.reconcile_warning_store import PostgreSQLReconcileWarningStore
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
from trex_core.adapters.portfolio.unmanaged_position_store import PostgreSQLUnmanagedPositionStore
__all__ = [
"PortfolioHubReadAdapter",
"PostgreSQLPauseStore",
"PostgreSQLReconcileWarningStore",
"PostgreSQLUnmanagedPositionStore",
"StubPortfolioRead",
]
@@ -0,0 +1,309 @@
"""PostgreSQL-backed unmanaged-position lifecycle store."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from trex_core.adapters.persistence.postgresql import _require_psycopg
_STATE_COLUMNS = (
"instance_key, symbol, quantity, lifecycle_state, "
"first_seen_at, last_seen_at, quantity_changed_at, resolved_at, "
"operator_action, evidence_ref, operator_note, operator_actor, "
"operator_updated_at, created_at, updated_at"
)
class PostgreSQLUnmanagedPositionStore:
"""CRUD + audit for unmanaged positions keyed by (instance_key, symbol)."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
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)
with self._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
cur.execute(
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
"WHERE instance_key = %s AND symbol = %s "
"FOR UPDATE",
(instance_key, symbol),
)
row = cur.fetchone()
previous = self._row_to_state(row) if row is not None else None
previous_quantity = previous.get("quantity") if previous is not None else None
if previous is None:
transition = "detected"
cur.execute(
"INSERT INTO unmanaged_position_state "
f"({_STATE_COLUMNS}) "
"VALUES (%s, %s, %s, 'active', %s, %s, %s, NULL, "
" NULL, NULL, '', NULL, NULL, %s, %s) "
f"RETURNING {_STATE_COLUMNS}",
(instance_key, symbol, quantity, now, now, now, now, now),
)
elif previous["lifecycle_state"] == "resolved":
transition = "detected"
cur.execute(
"UPDATE unmanaged_position_state "
"SET quantity = %s, lifecycle_state = 'active', "
" first_seen_at = %s, last_seen_at = %s, quantity_changed_at = %s, "
" resolved_at = NULL, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, now, instance_key, symbol),
)
elif int(previous["quantity"]) != int(quantity):
transition = "quantity_changed"
cur.execute(
"UPDATE unmanaged_position_state "
"SET quantity = %s, last_seen_at = %s, quantity_changed_at = %s, "
" resolved_at = NULL, lifecycle_state = 'active', updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, instance_key, symbol),
)
else:
transition = "unchanged"
cur.execute(
"UPDATE unmanaged_position_state "
"SET last_seen_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
(now, now, instance_key, symbol),
)
state = self._row_to_state(cur.fetchone())
if transition != "unchanged":
note = (
f"previous_quantity={previous_quantity}"
if transition == "quantity_changed" and previous_quantity is not None
else ""
)
self._insert_audit(
cur,
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=note,
created_at=now,
)
conn.commit()
return {
"transition": transition,
"previous_quantity": previous_quantity,
"state": 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._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
cur.execute(
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
"WHERE instance_key = %s AND lifecycle_state = 'active' "
"ORDER BY symbol "
"FOR UPDATE",
(instance_key,),
)
active = [self._row_to_state(row) for row in cur.fetchall()]
resolved: list[dict] = []
for state in active:
if state["symbol"] in observed_symbols:
continue
cur.execute(
"UPDATE unmanaged_position_state "
"SET lifecycle_state = 'resolved', resolved_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
(now, now, instance_key, state["symbol"]),
)
resolved_state = self._row_to_state(cur.fetchone())
resolved.append(resolved_state)
self._insert_audit(
cur,
instance_key=instance_key,
symbol=state["symbol"],
event_type="resolved",
action=None,
quantity=state["quantity"],
lifecycle_state="resolved",
actor=actor,
evidence_ref=resolved_state.get("evidence_ref"),
note="",
created_at=now,
)
conn.commit()
return resolved
def get_state(self, *, instance_key: str, symbol: str) -> dict | None:
with self._psycopg.connect(self._dsn) as conn, conn.cursor() as cur:
cur.execute(
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
"WHERE instance_key = %s AND symbol = %s "
"LIMIT 1",
(instance_key, symbol),
)
row = cur.fetchone()
return None if row is None else self._row_to_state(row)
def list_states(
self,
*,
instance_key: str,
lifecycle_state: str = "active",
symbol: str | None = None,
) -> list[dict]:
clauses = ["instance_key = %s"]
params: list[object] = [instance_key]
if lifecycle_state != "all":
clauses.append("lifecycle_state = %s")
params.append(lifecycle_state)
if symbol is not None:
clauses.append("symbol = %s")
params.append(symbol)
with self._psycopg.connect(self._dsn) as conn, conn.cursor() as cur:
cur.execute(
f"SELECT {_STATE_COLUMNS} FROM unmanaged_position_state "
f"WHERE {' AND '.join(clauses)} ORDER BY symbol",
tuple(params),
)
return [self._row_to_state(row) for row in cur.fetchall()]
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._psycopg.connect(self._dsn, autocommit=False) as conn, conn.cursor() as cur:
cur.execute(
"UPDATE unmanaged_position_state "
"SET operator_action = %s, evidence_ref = %s, operator_note = %s, "
" operator_actor = %s, operator_updated_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
(action, evidence_ref, note, actor, now, now, instance_key, symbol),
)
row = cur.fetchone()
if row is None:
conn.rollback()
return None
state = self._row_to_state(row)
self._insert_audit(
cur,
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,
)
conn.commit()
return state
def list_audit(self, *, instance_key: str, symbol: str) -> list[dict]:
with self._psycopg.connect(self._dsn) as conn, conn.cursor() as cur:
cur.execute(
"SELECT event_type, action, quantity, lifecycle_state, actor, "
" evidence_ref, note, created_at "
"FROM unmanaged_position_audit "
"WHERE instance_key = %s AND symbol = %s "
"ORDER BY audit_id",
(instance_key, symbol),
)
return [
{
"event_type": row[0],
"action": row[1],
"quantity": row[2],
"lifecycle_state": row[3],
"actor": row[4],
"evidence_ref": row[5],
"note": row[6],
"created_at": row[7],
}
for row in cur.fetchall()
]
def _insert_audit(
self,
cur,
*,
instance_key: str,
symbol: str,
event_type: str,
action: str | None,
quantity: int,
lifecycle_state: str,
actor: str,
evidence_ref: str | None,
note: str,
created_at: datetime,
) -> None:
cur.execute(
"INSERT INTO unmanaged_position_audit "
"(instance_key, symbol, event_type, action, quantity, lifecycle_state, "
" actor, evidence_ref, note, created_at) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
instance_key,
symbol,
event_type,
action,
quantity,
lifecycle_state,
actor,
evidence_ref,
note,
created_at,
),
)
@staticmethod
def _row_to_state(row) -> dict:
return {
"instance_key": row[0],
"symbol": row[1],
"quantity": row[2],
"lifecycle_state": row[3],
"first_seen_at": row[4],
"last_seen_at": row[5],
"quantity_changed_at": row[6],
"resolved_at": row[7],
"operator_action": row[8],
"evidence_ref": row[9],
"operator_note": row[10],
"operator_actor": row[11],
"operator_updated_at": row[12],
"created_at": row[13],
"updated_at": row[14],
}