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
@@ -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);
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user