"""PostgreSQL persistence adapters (REQ-006). - PostgreSQLSlotRepository: implements the domain SlotRepository protocol (single-writer, pg_advisory_xact_lock serialization). - PostgreSQLIntentStore: implements the application IntentStore port (intent_log dedup gate + audit). psycopg (v3) is imported lazily so the domain/test paths that use the in-memory adapters don't require it. External I/O lives here, never in the domain layer (NFR-1). """ from __future__ import annotations import json from contextlib import contextmanager from datetime import date, datetime, timezone from typing import Any, Iterator, Optional from uuid import UUID, uuid4 from trex_core.application.ports import IntentRecord from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord _SLOT_COLUMNS = ( "slot_id, trading_date, status, symbol, qty, avg_price, entry_price, " "allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, " "sell_strategy_id, forced_close_at, created_at, updated_at" ) def _require_psycopg(module: Any = None) -> Any: if module is not None: return module try: import psycopg # type: ignore[import-not-found] except ImportError as exc: # pragma: no cover - environment guard raise RuntimeError( "psycopg is required for PostgreSQL adapters. Install 'trex-core[postgres]'." ) from exc return psycopg class PostgreSQLSlotRepository: """psycopg-backed SlotRepository. Connection-per-call (orbit-proven).""" def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None: self._psycopg = _require_psycopg(psycopg_module) self._dsn = dsn @contextmanager def transactional_advisory_lock(self, lock_id: int) -> Iterator[None]: with self._psycopg.connect(self._dsn, autocommit=False) as conn: try: with conn.cursor() as cur: cur.execute("SELECT pg_advisory_xact_lock(%s)", (lock_id,)) yield conn.commit() except BaseException: conn.rollback() raise def pre_allocate_slots(self, trading_date: date, count: int) -> int: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "SELECT COUNT(*) FROM daily_slot WHERE trading_date = %s", (trading_date,) ) row = cur.fetchone() if row and row[0] > 0: conn.commit() return 0 now = datetime.now(tz=timezone.utc) rows = [ (uuid4(), trading_date, SlotState.IDLE.value, now, now) for _ in range(count) ] cur.executemany( "INSERT INTO daily_slot (slot_id, trading_date, status, created_at, updated_at) " "VALUES (%s, %s, %s, %s, %s)", rows, ) conn.commit() return count def list_slots(self, trading_date: date) -> list[Slot]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE trading_date = %s", (trading_date,), ) return [self._row_to_slot(r) for r in cur.fetchall()] def fetch_slot(self, slot_id: UUID) -> Optional[Slot]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE slot_id = %s", (slot_id,) ) row = cur.fetchone() return self._row_to_slot(row) if row else None def find_idle_slot(self, trading_date: date) -> Optional[Slot]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SLOT_COLUMNS} FROM daily_slot " "WHERE trading_date = %s AND status = 'idle' LIMIT 1", (trading_date,), ) row = cur.fetchone() return self._row_to_slot(row) if row else None def update_slot(self, slot: Slot) -> None: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, " "entry_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " "forced_close_at=%s, updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, slot.qty, slot.avg_price, slot.entry_price, slot.allocated_capital, slot.order_id, slot.strategy_id, slot.broker_order_id, slot.portfolio_id, slot.sell_strategy_id, slot.forced_close_at, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), ) if cur.rowcount == 0: conn.rollback() raise SlotNotFoundError(f"slot not found: {slot.slot_id}") conn.commit() def append_transition_log(self, record: SlotTransitionRecord) -> None: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO slot_transition_log " "(slot_id, from_state, to_state, ts, reason, actor, extra) " "VALUES (%s, %s, %s, %s, %s, %s, %s)", ( record.slot_id, record.from_state.value, record.to_state.value, record.ts, record.reason, record.actor, json.dumps(record.extra), ), ) conn.commit() def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO daily_symbol_entries (trading_date, symbol, entry_count, last_entry_at) " "VALUES (%s, %s, 1, %s) " "ON CONFLICT (trading_date, symbol) DO UPDATE " "SET entry_count = daily_symbol_entries.entry_count + 1, " " last_entry_at = EXCLUDED.last_entry_at RETURNING entry_count", (trading_date, symbol, datetime.now(tz=timezone.utc)), ) row = cur.fetchone() conn.commit() return int(row[0]) if row else 0 def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( "SELECT entry_count FROM daily_symbol_entries " "WHERE trading_date = %s AND symbol = %s", (trading_date, symbol), ) row = cur.fetchone() return int(row[0]) if row else 0 def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( "SELECT status, COUNT(*) FROM daily_slot WHERE trading_date = %s GROUP BY status", (trading_date,), ) return {SlotState(r[0]): int(r[1]) for r in cur.fetchall()} @staticmethod def _row_to_slot(row: tuple) -> Slot: f = lambda v: float(v) if v is not None else None # noqa: E731 return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), trading_date=row[1], status=SlotState(row[2]), symbol=row[3], qty=f(row[4]), avg_price=f(row[5]), entry_price=f(row[6]), allocated_capital=f(row[7]), order_id=row[8], strategy_id=row[9], broker_order_id=row[10], portfolio_id=row[11], sell_strategy_id=row[12], forced_close_at=row[13], created_at=row[14], updated_at=row[15], ) class PostgreSQLIntentStore: """intent_log-backed IntentStore. intent_id UNIQUE is the dedup gate.""" def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None: self._psycopg = _require_psycopg(psycopg_module) self._dsn = dsn def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO intent_log (intent_id, intent_type, payload, status) " "VALUES (%s, %s, %s, 'processing') ON CONFLICT (intent_id) DO NOTHING", (intent_id, intent_type, json.dumps(payload)), ) inserted = cur.rowcount == 1 conn.commit() return inserted def get(self, intent_id: str) -> Optional[IntentRecord]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( "SELECT intent_id, intent_type, status, slot_id, reason " "FROM intent_log WHERE intent_id = %s", (intent_id,), ) row = cur.fetchone() if row is None: return None return _PgIntentRecord( intent_id=row[0], intent_type=row[1], status=row[2], slot_id=(row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))), reason=row[4], ) def finalize( self, intent_id: str, status: str, slot_id: Optional[UUID], reason: Optional[str] ) -> None: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: cur.execute( "UPDATE intent_log SET status=%s, slot_id=%s, reason=%s, updated_at=%s " "WHERE intent_id=%s", (status, slot_id, reason, datetime.now(tz=timezone.utc), intent_id), ) conn.commit() class _PgIntentRecord: def __init__(self, intent_id, intent_type, status, slot_id, reason): self.intent_id = intent_id self.intent_type = intent_type self.status = status self.slot_id = slot_id self.reason = reason