chore: plan for review blockers follow-up

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-13 06:21:10 +00:00
committed by GitHub
co-authored by genichin
53 changed files with 3461 additions and 310 deletions
+4 -3
View File
@@ -386,9 +386,7 @@ class KISBroker:
transport_failure: bool,
) -> float:
"""Return bounded state/age-based delay for the next authoritative poll."""
if status.state != "unknown":
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 60:
if status.state != "unknown" or age_seconds < 60:
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 5 * 60:
delay = 15.0
@@ -1290,6 +1288,9 @@ class KISBroker:
seen_continuations.add(next_continuation)
continuation = next_continuation
def exposes_full_balance_snapshot(self) -> bool:
return True
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
"""Return KIS cash-only buy capacity (`nrcvb_buy_amt`).
+10 -3
View File
@@ -13,11 +13,11 @@ import os
from datetime import datetime
from pathlib import Path
from threading import Lock
from typing import Optional
from trex_core.application.broker_port import (
BalanceItem,
BrokerBalanceError,
BrokerPort,
CancelOutcome,
FillStatus,
OrderAck,
@@ -134,8 +134,8 @@ class OwnershipAwareBroker:
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
order_date: str | None = None,
submitted_at: datetime | None = None,
) -> FillStatus:
result = self._delegate.poll_fill(
broker_order_id,
@@ -175,6 +175,13 @@ class OwnershipAwareBroker:
def get_balance(self) -> list[BalanceItem]:
return self._store.project(self._delegate.get_balance())
def authoritative_balance_reader(self) -> BrokerPort:
return self._delegate
def exposes_full_balance_snapshot(self) -> bool:
"""False: projected balances omit operator-owned/unmanaged broker positions."""
return False
def account_scope_hash(self) -> str:
return self._delegate.account_scope_hash()
+3
View File
@@ -166,6 +166,9 @@ class PaperBroker:
return [BalanceItem(symbol=sym, hldg_qty=qty)
for sym, qty in balances.items() if qty != 0]
def exposes_full_balance_snapshot(self) -> bool:
return True
# --- REQ-038: programmable current price (safety-sell scenario double) ---
def set_current_price(self, symbol: str, price: Decimal) -> None:
"""Program the current price returned by get_current_price(symbol)."""
+114 -21
View File
@@ -13,22 +13,24 @@ domain layer (NFR-1).
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Optional
from typing import Any, Optional
from uuid import UUID, uuid4
from trex_core.application.ledger_outbox import (
LedgerWorkItem,
OutboxClaimLostError,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.application.ports import IntentRecord
from trex_core.application.strategy_state import (
_STATE_CAP_BYTES,
SetResult,
StrategyStateEntry,
_STATE_CAP_BYTES,
)
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
@@ -565,16 +567,44 @@ class _PgIntentRecord:
_OUTBOX_COLUMNS = (
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
"created_at, updated_at, next_retry_at, venue, order_venue"
"created_at, updated_at, next_retry_at, venue, order_venue, instance_key, "
"claim_expires_at"
)
_OUTBOX_RETURN_COLUMNS = ", ".join(
f"o.{column.strip()}" for column in _OUTBOX_COLUMNS.split(",")
)
class PostgreSQLLedgerOutbox:
"""ledger_outbox-backed LedgerOutbox. correlation_id PK is the dedup gate."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
def __init__(
self,
dsn: str,
*,
instance_key: str,
claim_ttl_sec: float = 60.0,
worker_id: str | None = None,
clock=None,
psycopg_module: Any = None,
) -> None:
if not isinstance(instance_key, str) or not instance_key.strip():
raise ValueError("instance_key must be a non-empty string")
if claim_ttl_sec <= 0:
raise ValueError("claim_ttl_sec must be positive")
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
self._instance_key = instance_key
self._claim_ttl_sec = claim_ttl_sec
self._worker_id = worker_id or f"ledger-writer-{uuid4().hex}"
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
def _now(self) -> datetime:
return self._clock()
@property
def instance_key(self) -> str:
return self._instance_key
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
@@ -582,8 +612,8 @@ class PostgreSQLLedgerOutbox:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at, venue, order_venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
" qty, price, fee, tax, executed_at, venue, order_venue, instance_key) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id,
@@ -598,6 +628,7 @@ class PostgreSQLLedgerOutbox:
trade.executed_at,
trade.venue,
trade.order_venue,
self._instance_key,
),
)
conn.commit()
@@ -606,17 +637,59 @@ class PostgreSQLLedgerOutbox:
return existing
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
now = self._now()
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox "
"WHERE status = 'pending' "
"AND (next_retry_at IS NULL OR next_retry_at <= now()) "
"ORDER BY created_at LIMIT %s",
(limit,),
"AND instance_key = %s "
"AND (next_retry_at IS NULL OR next_retry_at <= %s) "
"AND (claim_expires_at IS NULL OR claim_expires_at <= %s) "
"ORDER BY created_at, correlation_id LIMIT %s",
(self._instance_key, now, now, limit),
)
return [self._row_to_item(r) for r in cur.fetchall()]
def claim_pending(
self,
limit: int = 32,
*,
correlation_id: str | None = None,
) -> list[LedgerWorkItem]:
now = self._now()
lease_until = now + timedelta(seconds=self._claim_ttl_sec)
correlation_sql = ""
params: list[Any] = [self._instance_key, now, now]
if correlation_id is not None:
correlation_sql = "AND correlation_id = %s "
params.append(correlation_id)
params.extend([limit, self._worker_id, lease_until, now])
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"WITH candidates AS ("
"SELECT correlation_id FROM ledger_outbox "
"WHERE status = 'pending' "
"AND instance_key = %s "
"AND (next_retry_at IS NULL OR next_retry_at <= %s) "
"AND (claim_expires_at IS NULL OR claim_expires_at <= %s) "
f"{correlation_sql}"
"ORDER BY created_at, correlation_id "
"LIMIT %s "
"FOR UPDATE SKIP LOCKED"
") "
"UPDATE ledger_outbox o "
"SET claim_owner = %s, claim_expires_at = %s, updated_at = %s "
"FROM candidates "
"WHERE o.correlation_id = candidates.correlation_id "
f"RETURNING {_OUTBOX_RETURN_COLUMNS}",
params,
)
rows = [self._row_to_item(r) for r in cur.fetchall()]
conn.commit()
return rows
def pending_cash_delta(self) -> dict[str, float]:
# dcy-018 / REQ-029 (OQ-4): estimated cash delta of all pending rows by
# asset_id. SUM in SQL: sell → +(qty*price - fee - tax), buy → negative.
@@ -627,7 +700,10 @@ class PostgreSQLLedgerOutbox:
"SUM(CASE WHEN side='sell' "
" THEN (qty*price - fee - tax) "
" ELSE -(qty*price + fee + tax) END) "
"FROM ledger_outbox WHERE status='pending' GROUP BY asset_id"
"FROM ledger_outbox "
"WHERE status='pending' AND instance_key=%s "
"GROUP BY asset_id",
(self._instance_key,),
)
return {r[0]: float(r[1]) for r in cur.fetchall() if r[1] is not None}
@@ -648,6 +724,7 @@ class PostgreSQLLedgerOutbox:
*,
error: Optional[str] = None,
next_retry_at: Optional[datetime] = None,
expected_claim_expires_at: datetime | None = None,
) -> LedgerWorkItem:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
@@ -669,20 +746,34 @@ class PostgreSQLLedgerOutbox:
attempts_delta = 1
# next_retry_at only persists for a retryable backoff; cleared otherwise.
nra = next_retry_at if outcome is OutboxOutcome.RETRYABLE_FAILURE else None
params: list[Any] = [
status_value,
attempts_delta,
outcome.value,
error,
nra,
self._now(),
correlation_id,
self._instance_key,
]
where = "WHERE correlation_id=%s AND instance_key=%s "
if expected_claim_expires_at is None:
where += "AND claim_owner IS NULL AND claim_expires_at IS NULL"
else:
where += (
"AND claim_owner=%s "
"AND (claim_expires_at=%s OR (status='pending' AND claim_expires_at<=%s))"
)
params.extend([self._worker_id, expected_claim_expires_at, self._now()])
cur.execute(
"UPDATE ledger_outbox SET status=%s, attempts=attempts+%s, "
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s "
"WHERE correlation_id=%s",
(
status_value,
attempts_delta,
outcome.value,
error,
nra,
datetime.now(tz=timezone.utc),
correlation_id,
),
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s, "
"claim_owner=NULL, claim_expires_at=NULL "
f"{where}",
params,
)
if cur.rowcount != 1:
raise OutboxClaimLostError(correlation_id)
conn.commit()
row = self.get(correlation_id)
assert row is not None
@@ -714,6 +805,8 @@ class PostgreSQLLedgerOutbox:
created_at=row[14],
updated_at=row[15],
next_retry_at=row[16],
instance_key=row[19],
claim_expires_at=row[20],
)
@@ -1,6 +1,6 @@
-- BAT-050 / REQ-102: reconcile-warning durable store (dcy-034).
-- PK: slot_id (TEXT, UUID string). pause_store 동형 설계.
-- ON CONFLICT DO UPDATE: 동일 slot_id 재삽입 시 최신 값으로 갱신.
-- Initial schema; migration 028 upgrades PK to (slot_id, case_code)
-- so B/C causes can coexist for the same slot.
CREATE TABLE IF NOT EXISTS slot_reconcile_warning (
slot_id TEXT PRIMARY KEY,
portfolio_id TEXT NOT NULL,
@@ -0,0 +1,30 @@
-- REQ-123 / issue #83:
-- Preserve simultaneous warning causes by keying warnings per (slot_id, case_code).
-- NOTE: historical overwritten causes cannot be reconstructed from the prior
-- slot_id-only schema; this migration enables forward preservation only.
UPDATE slot_reconcile_warning
SET case_code = 'B'
WHERE case_code IS NULL OR case_code = '';
ALTER TABLE slot_reconcile_warning
ALTER COLUMN case_code SET NOT NULL;
DELETE FROM slot_reconcile_warning w
USING (
SELECT ctid, ROW_NUMBER() OVER (
PARTITION BY slot_id, case_code
ORDER BY updated_at DESC, created_at DESC, ctid DESC
) AS rn
FROM slot_reconcile_warning
) ranked
WHERE w.ctid = ranked.ctid
AND ranked.rn > 1;
ALTER TABLE slot_reconcile_warning
DROP CONSTRAINT IF EXISTS slot_reconcile_warning_pkey;
ALTER TABLE slot_reconcile_warning
ADD CONSTRAINT slot_reconcile_warning_pkey PRIMARY KEY (slot_id, case_code);
CREATE INDEX IF NOT EXISTS idx_slot_reconcile_warning_slot_id
ON slot_reconcile_warning (slot_id);
@@ -0,0 +1,23 @@
-- GitHub #84: persist outbox owner instance and add claim/lease fields so
-- shared-DB daemon writers only deliver their own rows and can safely recover
-- abandoned work after lease expiry. Existing rows remain fail-closed when the
-- owner instance cannot be inferred.
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS instance_key TEXT;
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS claim_owner TEXT;
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS claim_expires_at TIMESTAMPTZ;
UPDATE ledger_outbox
SET instance_key='__unowned__'
WHERE instance_key IS NULL;
ALTER TABLE ledger_outbox ALTER COLUMN instance_key SET DEFAULT '__unowned__';
ALTER TABLE ledger_outbox ALTER COLUMN instance_key SET NOT NULL;
DROP INDEX IF EXISTS idx_ledger_outbox_instance_pending;
CREATE INDEX IF NOT EXISTS idx_ledger_outbox_instance_pending
ON ledger_outbox (instance_key, status, created_at, correlation_id)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_ledger_outbox_instance_claim_expires
ON ledger_outbox (instance_key, claim_expires_at)
WHERE status = 'pending';
@@ -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",
]
@@ -1,8 +1,4 @@
"""PostgreSQL-backed durable reconcile-warning store (REQ-102, BAT-050).
pause_store.py와 동형 설계. PK: slot_id. case_code: 'B' | 'C'.
Survives daemon restart (dcy-034 NFR-1).
"""
"""PostgreSQL-backed durable reconcile-warning store (REQ-102, BAT-050)."""
from __future__ import annotations
from datetime import datetime, timezone
@@ -12,7 +8,7 @@ from trex_core.adapters.persistence.postgresql import _require_psycopg
class PostgreSQLReconcileWarningStore:
"""`slot_reconcile_warning` table CRUD. slot_id PK is the gate (REQ-102)."""
"""`slot_reconcile_warning` table CRUD keyed by (slot_id, case_code)."""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
@@ -34,9 +30,8 @@ class PostgreSQLReconcileWarningStore:
"INSERT INTO slot_reconcile_warning "
"(slot_id, portfolio_id, case_code, actor, reason, created_at, updated_at) "
"VALUES (%s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (slot_id) DO UPDATE "
"ON CONFLICT (slot_id, case_code) DO UPDATE "
"SET portfolio_id = EXCLUDED.portfolio_id, "
" case_code = EXCLUDED.case_code, "
" actor = EXCLUDED.actor, "
" reason = EXCLUDED.reason, "
" updated_at = EXCLUDED.updated_at",
@@ -44,13 +39,19 @@ class PostgreSQLReconcileWarningStore:
)
conn.commit()
def clear_warned(self, slot_id: str) -> None:
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM slot_reconcile_warning WHERE slot_id = %s",
(slot_id,),
)
if case_code is None:
cur.execute(
"DELETE FROM slot_reconcile_warning WHERE slot_id = %s",
(slot_id,),
)
else:
cur.execute(
"DELETE FROM slot_reconcile_warning WHERE slot_id = %s AND case_code = %s",
(slot_id, case_code),
)
conn.commit()
def is_warned(self, slot_id: str) -> bool:
@@ -65,7 +66,7 @@ class PostgreSQLReconcileWarningStore:
def list_warned(self) -> set[str]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute("SELECT slot_id FROM slot_reconcile_warning")
cur.execute("SELECT DISTINCT slot_id FROM slot_reconcile_warning")
return {row[0] for row in cur.fetchall()}
def list_c_case_warned(self) -> set[str]:
@@ -73,26 +74,30 @@ class PostgreSQLReconcileWarningStore:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT slot_id FROM slot_reconcile_warning WHERE case_code = %s",
"SELECT DISTINCT slot_id FROM slot_reconcile_warning WHERE case_code = %s",
("C",),
)
return {row[0] for row in cur.fetchall()}
def get_warning_info(self, slot_id: str) -> dict | None:
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None (REQ-105/106)."""
"""Return warning info with full active case set, or None."""
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT portfolio_id, case_code, actor, reason "
"FROM slot_reconcile_warning WHERE slot_id = %s LIMIT 1",
"FROM slot_reconcile_warning WHERE slot_id = %s "
"ORDER BY CASE WHEN case_code = 'C' THEN 0 ELSE 1 END, updated_at DESC",
(slot_id,),
)
row = cur.fetchone()
if row is None:
rows = cur.fetchall()
if not rows:
return None
case_codes = sorted({str(row[1]) for row in rows if row[1]})
preferred = rows[0]
return {
"portfolio_id": row[0],
"case_code": row[1],
"actor": row[2],
"reason": row[3],
"portfolio_id": preferred[0],
"case_code": preferred[1],
"case_codes": case_codes,
"actor": preferred[2],
"reason": preferred[3],
}
@@ -0,0 +1,379 @@
"""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
@staticmethod
def _latest_snapshot_at(state: dict) -> datetime:
resolved_at = state.get("resolved_at")
last_seen_at = state.get("last_seen_at")
if isinstance(resolved_at, datetime) and (
not isinstance(last_seen_at, datetime) or resolved_at >= last_seen_at
):
return resolved_at
if isinstance(last_seen_at, datetime):
return last_seen_at
return state.get("updated_at") or state.get("created_at") or datetime.min.replace(tzinfo=UTC)
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)
for _attempt in range(2):
try:
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 not None and now < self._latest_snapshot_at(previous):
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
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":
if now <= self._latest_snapshot_at(previous):
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
transition = "detected"
cur.execute(
"UPDATE unmanaged_position_state "
"SET quantity = %s, lifecycle_state = 'active', "
" last_seen_at = %s, quantity_changed_at = %s, resolved_at = NULL, "
" operator_action = NULL, evidence_ref = NULL, operator_note = '', "
" operator_actor = NULL, operator_updated_at = NULL, "
" updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'resolved' AND resolved_at < %s "
f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, instance_key, symbol, now),
)
elif int(previous["quantity"]) != int(quantity):
if now <= previous["last_seen_at"]:
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
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 "
" AND lifecycle_state = 'active' AND last_seen_at < %s "
f"RETURNING {_STATE_COLUMNS}",
(quantity, now, now, now, instance_key, symbol, now),
)
else:
transition = "unchanged"
if now > previous["last_seen_at"]:
cur.execute(
"UPDATE unmanaged_position_state "
"SET last_seen_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'active' AND quantity = %s AND last_seen_at < %s "
f"RETURNING {_STATE_COLUMNS}",
(now, now, instance_key, symbol, quantity, now),
)
fetched = cur.fetchone()
state = previous if fetched is None else self._row_to_state(fetched)
else:
conn.rollback()
return {
"transition": "unchanged",
"previous_quantity": previous_quantity,
"state": previous,
}
if transition != "unchanged":
fetched = cur.fetchone()
if fetched is None:
conn.rollback()
return {
"transition": "stale",
"previous_quantity": previous_quantity,
"state": previous,
}
state = self._row_to_state(fetched)
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,
}
except self._psycopg.errors.UniqueViolation:
continue
raise RuntimeError(
"failed to persist unmanaged_position_state due to repeated concurrent insert race"
)
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
if now <= state["last_seen_at"]:
continue
cur.execute(
"UPDATE unmanaged_position_state "
"SET lifecycle_state = 'resolved', resolved_at = %s, updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
" AND lifecycle_state = 'active' AND last_seen_at < %s "
f"RETURNING {_STATE_COLUMNS}",
(now, now, instance_key, state["symbol"], now),
)
row = cur.fetchone()
if row is None:
continue
resolved_state = self._row_to_state(row)
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],
"recorded_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],
}