902 lines
38 KiB
Python
902 lines
38 KiB
Python
"""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 collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
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,
|
|
)
|
|
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
|
|
|
_SLOT_COLUMNS = (
|
|
"slot_id, status, symbol, qty, avg_price, limit_price, "
|
|
"order_id, strategy_id, broker_order_id, portfolio_id, "
|
|
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
|
|
"created_at, updated_at, instance_key, retired_at, "
|
|
"timeout_sec, slippage_pct, occupancy_seq, venue, "
|
|
"cancel_state, cancel_requested_at, order_submitted_at, order_date, cancel_attempts"
|
|
)
|
|
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
|
|
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 list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
if status_filter:
|
|
# issue #25: filter by status at the DB instead of fetching
|
|
# every row and filtering in Python. ANY(%s) binds the set
|
|
# as a single PG array parameter.
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
|
"WHERE instance_key = %s AND status = ANY(%s)",
|
|
(instance_key, list(status_filter)),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE instance_key = %s",
|
|
(instance_key,),
|
|
)
|
|
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
|
|
|
|
# --- asset-centric (dcy-020 / BAT-028) ---
|
|
def find_slot_by_portfolio(self, instance_key: str, portfolio_id: str) -> 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 instance_key = %s AND portfolio_id = %s LIMIT 1",
|
|
(instance_key, portfolio_id),
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_slot(row) if row else None
|
|
|
|
def count_active(self, instance_key: str) -> int:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT count(*) FROM daily_slot WHERE instance_key = %s "
|
|
"AND status IN ('holding','pending_buy','pending_sell')",
|
|
(instance_key,),
|
|
)
|
|
row = cur.fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
def list_holding_slots(self, instance_key: str) -> 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 instance_key = %s AND status = 'holding'",
|
|
(instance_key,),
|
|
)
|
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
|
|
|
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> 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 instance_key = %s AND symbol = %s "
|
|
"ORDER BY updated_at DESC NULLS LAST, slot_id ASC LIMIT 1",
|
|
(instance_key, symbol),
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_slot(row) if row else None
|
|
|
|
def reconcile_universe(
|
|
self,
|
|
instance_key: str,
|
|
universe: dict[str, str],
|
|
*,
|
|
allow_retire: bool = True,
|
|
) -> dict[str, int]:
|
|
now = datetime.now(tz=timezone.utc)
|
|
created = retired = tombstoned = 0
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT portfolio_id, status, retired_at FROM daily_slot "
|
|
"WHERE instance_key = %s AND portfolio_id IS NOT NULL",
|
|
(instance_key,),
|
|
)
|
|
existing = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
|
|
# backfill symbol on slots that were created before this field was populated
|
|
cur.execute(
|
|
"SELECT portfolio_id FROM daily_slot "
|
|
"WHERE instance_key = %s AND portfolio_id IS NOT NULL AND symbol IS NULL",
|
|
(instance_key,),
|
|
)
|
|
null_symbol_pids = {r[0] for r in cur.fetchall()}
|
|
for pid in null_symbol_pids:
|
|
if pid in universe:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET symbol = %s, updated_at = %s "
|
|
"WHERE instance_key = %s AND portfolio_id = %s AND symbol IS NULL",
|
|
(universe[pid], now, instance_key, pid),
|
|
)
|
|
# create new asset slots (idempotent via partial unique)
|
|
for pid, symbol in universe.items():
|
|
if pid in existing:
|
|
continue
|
|
cur.execute(
|
|
"INSERT INTO daily_slot "
|
|
"(slot_id, status, symbol, instance_key, portfolio_id, "
|
|
"created_at, updated_at) VALUES (%s, 'inactive', %s, %s, %s, %s, %s) "
|
|
"ON CONFLICT (instance_key, portfolio_id) "
|
|
"WHERE portfolio_id IS NOT NULL DO NOTHING",
|
|
(uuid4(), symbol, instance_key, pid, now, now),
|
|
)
|
|
created += cur.rowcount
|
|
# retire / tombstone assets that left the universe (skipped under
|
|
# the mass-disappearance guard, dcy-020/OQ-2 D3)
|
|
if allow_retire:
|
|
for pid, (status, retired_at) in existing.items():
|
|
if pid in universe:
|
|
continue
|
|
if status in ("holding", "pending_buy", "pending_sell"):
|
|
tombstoned += 1
|
|
elif retired_at is None:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET retired_at = %s, updated_at = %s "
|
|
"WHERE instance_key = %s AND portfolio_id = %s",
|
|
(now, now, instance_key, pid),
|
|
)
|
|
retired += 1
|
|
conn.commit()
|
|
return {"created": created, "retired": retired, "tombstoned": tombstoned}
|
|
|
|
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, "
|
|
"limit_price=%s, order_id=%s, strategy_id=%s, "
|
|
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
|
|
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
|
|
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
|
|
"occupancy_seq=%s, venue=%s, cancel_state=%s, "
|
|
"cancel_requested_at=%s, order_submitted_at=%s, order_date=%s, "
|
|
"cancel_attempts=%s, updated_at=%s WHERE slot_id=%s",
|
|
(
|
|
slot.status.value,
|
|
slot.symbol,
|
|
slot.qty,
|
|
slot.avg_price,
|
|
slot.limit_price,
|
|
slot.order_id,
|
|
slot.strategy_id,
|
|
slot.broker_order_id,
|
|
slot.portfolio_id,
|
|
slot.sell_strategy_id,
|
|
slot.forced_close_at,
|
|
slot.stop_loss_price,
|
|
slot.take_profit_price,
|
|
slot.instance_key,
|
|
slot.retired_at,
|
|
slot.timeout_sec,
|
|
slot.slippage_pct,
|
|
slot.occupancy_seq,
|
|
slot.venue,
|
|
slot.cancel_state,
|
|
slot.cancel_requested_at,
|
|
slot.order_submitted_at,
|
|
slot.order_date,
|
|
slot.cancel_attempts,
|
|
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()
|
|
|
|
# --- order execution queue (BAT-004 / REQ-013, option A) ---
|
|
def list_undispatched_pending(
|
|
self,
|
|
instance_key: str = "default",
|
|
limit: int = 64,
|
|
) -> list[Slot]:
|
|
# dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a
|
|
# DB never dispatch each other's orders. List-based (vs single-row
|
|
# claim) so one skipped sell (broker flat) cannot head-block the queue.
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
|
"WHERE instance_key = %s "
|
|
"AND status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL "
|
|
"ORDER BY updated_at LIMIT %s",
|
|
(instance_key, limit),
|
|
)
|
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
|
|
|
def mark_dispatched(
|
|
self,
|
|
slot_id: UUID,
|
|
broker_order_id: str,
|
|
*,
|
|
venue: Optional[str] = None,
|
|
submitted_at: Optional[datetime] = None,
|
|
) -> bool:
|
|
submitted_at = submitted_at or datetime.now(tz=timezone.utc)
|
|
order_date = submitted_at.astimezone(_KST).strftime("%Y%m%d")
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET broker_order_id=%s, "
|
|
"venue=COALESCE(%s, venue), order_submitted_at=%s, order_date=%s, "
|
|
"cancel_attempts=0, updated_at=%s "
|
|
"WHERE slot_id=%s AND broker_order_id IS NULL",
|
|
(
|
|
broker_order_id,
|
|
venue,
|
|
submitted_at,
|
|
order_date,
|
|
datetime.now(tz=timezone.utc),
|
|
slot_id,
|
|
),
|
|
)
|
|
ok = cur.rowcount == 1
|
|
conn.commit()
|
|
return ok
|
|
|
|
def replace_dispatch(
|
|
self,
|
|
slot_id: UUID,
|
|
expected_broker_order_id: str,
|
|
broker_order_id: str,
|
|
) -> bool:
|
|
"""Atomically persist a broker order ID resolved after submit timeout."""
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
|
|
"WHERE slot_id=%s AND broker_order_id=%s",
|
|
(
|
|
broker_order_id,
|
|
datetime.now(tz=timezone.utc),
|
|
slot_id,
|
|
expected_broker_order_id,
|
|
),
|
|
)
|
|
ok = cur.rowcount == 1
|
|
conn.commit()
|
|
return ok
|
|
|
|
def mark_cancel_outcome(
|
|
self,
|
|
slot_id: UUID,
|
|
broker_order_id: str,
|
|
state: str,
|
|
*,
|
|
only_if_unset: bool = False,
|
|
) -> bool:
|
|
now = datetime.now(tz=timezone.utc)
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET cancel_state=%s, "
|
|
"cancel_requested_at=%s, updated_at=%s "
|
|
"WHERE slot_id=%s AND broker_order_id=%s "
|
|
"AND status IN ('pending_buy','pending_sell') "
|
|
"AND (%s = FALSE OR cancel_state IS NULL)",
|
|
(
|
|
state,
|
|
now,
|
|
now,
|
|
slot_id,
|
|
broker_order_id,
|
|
only_if_unset,
|
|
),
|
|
)
|
|
ok = cur.rowcount == 1
|
|
conn.commit()
|
|
return ok
|
|
|
|
def reserve_cancel_attempt(
|
|
self,
|
|
slot_id: UUID,
|
|
broker_order_id: str,
|
|
*,
|
|
retry_before: Optional[datetime] = None,
|
|
) -> bool:
|
|
"""Atomically reserve first cancel or an authoritative backoff-gated retry.
|
|
|
|
Two separate SQL paths avoid nullable-placeholder type-inference errors
|
|
(PostgreSQL IndeterminateDatatype when binding None to %s IS NOT NULL).
|
|
"""
|
|
now = datetime.now(tz=timezone.utc)
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
if retry_before is None:
|
|
# First reservation: only match when no cancel has been
|
|
# attempted yet (cancel_state IS NULL).
|
|
cur.execute(
|
|
"UPDATE daily_slot SET cancel_state='unknown', cancel_requested_at=%s, "
|
|
"cancel_attempts=cancel_attempts+1, updated_at=%s "
|
|
"WHERE slot_id=%s AND broker_order_id=%s "
|
|
"AND status IN ('pending_buy','pending_sell') "
|
|
"AND cancel_state IS NULL",
|
|
(now, now, slot_id, broker_order_id),
|
|
)
|
|
else:
|
|
# Retry: backoff-gated, only match retryable states.
|
|
cur.execute(
|
|
"UPDATE daily_slot SET cancel_state='unknown', cancel_requested_at=%s, "
|
|
"cancel_attempts=cancel_attempts+1, updated_at=%s "
|
|
"WHERE slot_id=%s AND broker_order_id=%s "
|
|
"AND status IN ('pending_buy','pending_sell') "
|
|
"AND cancel_state IN ('rejected','unknown') "
|
|
"AND cancel_attempts < 4 "
|
|
"AND cancel_requested_at <= %s",
|
|
(now, now, slot_id, broker_order_id, retry_before),
|
|
)
|
|
ok = cur.rowcount == 1
|
|
conn.commit()
|
|
return ok
|
|
|
|
def list_dispatched_pending(self, instance_key: str = "default") -> 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 instance_key = %s "
|
|
"AND status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL",
|
|
(instance_key,),
|
|
)
|
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
|
|
|
def clear_dispatch(self, slot_id: UUID) -> None:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET broker_order_id=NULL, cancel_state=NULL, "
|
|
"cancel_requested_at=NULL, order_submitted_at=NULL, order_date=NULL, "
|
|
"cancel_attempts=0, updated_at=%s WHERE slot_id=%s",
|
|
(datetime.now(tz=timezone.utc), slot_id),
|
|
)
|
|
conn.commit()
|
|
|
|
def count_slots_by_status(self, instance_key: str) -> 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 instance_key = %s "
|
|
"GROUP BY status",
|
|
(instance_key,),
|
|
)
|
|
return {SlotState(r[0]): int(r[1]) for r in cur.fetchall()}
|
|
|
|
@staticmethod
|
|
def _row_to_slot(row: tuple) -> Slot:
|
|
from decimal import Decimal
|
|
|
|
f = lambda v: float(v) if v is not None else None # noqa: E731
|
|
d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731
|
|
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
|
|
# limit_price(5) order_id(6) strategy_id(7)
|
|
# broker_order_id(8) portfolio_id(9) sell_strategy_id(10)
|
|
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
|
|
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
|
|
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
|
|
# occupancy_seq(20) [dcy-028/REQ-081], venue(21),
|
|
# cancel_state(22), cancel_requested_at(23), order_submitted_at(24),
|
|
# order_date(25), cancel_attempts(26)
|
|
return Slot(
|
|
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
|
status=SlotState(row[1]),
|
|
symbol=row[2],
|
|
qty=f(row[3]),
|
|
avg_price=f(row[4]),
|
|
limit_price=f(row[5]),
|
|
order_id=row[6],
|
|
strategy_id=row[7],
|
|
broker_order_id=row[8],
|
|
portfolio_id=row[9],
|
|
sell_strategy_id=row[10],
|
|
forced_close_at=row[11],
|
|
stop_loss_price=d(row[12]),
|
|
take_profit_price=d(row[13]),
|
|
created_at=row[14],
|
|
updated_at=row[15],
|
|
instance_key=row[16] if row[16] is not None else "default",
|
|
retired_at=row[17],
|
|
timeout_sec=int(row[18]) if row[18] is not None else None,
|
|
slippage_pct=f(row[19]),
|
|
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0,
|
|
venue=str(row[21]) if len(row) > 21 and row[21] is not None else "KRX",
|
|
cancel_state=str(row[22]) if len(row) > 22 and row[22] is not None else None,
|
|
cancel_requested_at=row[23] if len(row) > 23 else None,
|
|
order_submitted_at=row[24] if len(row) > 24 else None,
|
|
order_date=str(row[25]) if len(row) > 25 and row[25] is not None else None,
|
|
cancel_attempts=int(row[26]) if len(row) > 26 and row[26] is not None else 0,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- ledger outbox (BAT-006 / REQ-019) ---------------------------------------
|
|
|
|
_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, 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,
|
|
*,
|
|
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:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO ledger_outbox "
|
|
"(correlation_id, asset_id, order_id, execution_id, side, "
|
|
" 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,
|
|
trade.asset_id,
|
|
trade.order_id,
|
|
trade.execution_id,
|
|
trade.side,
|
|
trade.qty,
|
|
trade.price,
|
|
trade.fee,
|
|
trade.tax,
|
|
trade.executed_at,
|
|
trade.venue,
|
|
trade.order_venue,
|
|
self._instance_key,
|
|
),
|
|
)
|
|
conn.commit()
|
|
existing = self.get(trade.correlation_id)
|
|
assert existing is not None
|
|
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 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.
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT asset_id, "
|
|
"SUM(CASE WHEN side='sell' "
|
|
" THEN (qty*price - fee - tax) "
|
|
" ELSE -(qty*price + fee + tax) END) "
|
|
"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}
|
|
|
|
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox WHERE correlation_id = %s",
|
|
(correlation_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_item(row) if row else None
|
|
|
|
def mark_outcome(
|
|
self,
|
|
correlation_id: str,
|
|
outcome: OutboxOutcome,
|
|
*,
|
|
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:
|
|
if outcome is OutboxOutcome.COMPLETED:
|
|
status_value = WorkItemStatus.COMPLETED.value
|
|
attempts_delta = 1
|
|
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
|
status_value = WorkItemStatus.CONFLICT_BLOCKED.value
|
|
attempts_delta = 1
|
|
elif outcome in (OutboxOutcome.PERMANENT_FAILURE, OutboxOutcome.DEAD_LETTERED):
|
|
# dcy-015 / REQ-019: terminal delivery_failed (reason in last_outcome).
|
|
status_value = WorkItemStatus.DELIVERY_FAILED.value
|
|
attempts_delta = 1
|
|
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
|
status_value = WorkItemStatus.COMPLETED.value
|
|
attempts_delta = 0
|
|
else: # retryable
|
|
status_value = WorkItemStatus.PENDING.value
|
|
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, "
|
|
"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
|
|
return row
|
|
|
|
@staticmethod
|
|
def _row_to_item(row: tuple) -> LedgerWorkItem:
|
|
f = lambda v: float(v) if v is not None else 0.0 # noqa: E731
|
|
trade = LedgerTrade(
|
|
asset_id=row[1],
|
|
order_id=row[2],
|
|
execution_id=row[3],
|
|
side=row[4],
|
|
qty=f(row[5]),
|
|
price=f(row[6]),
|
|
fee=f(row[7]),
|
|
tax=f(row[8]),
|
|
executed_at=row[9],
|
|
venue=row[17],
|
|
order_venue=row[18],
|
|
)
|
|
return LedgerWorkItem(
|
|
correlation_id=row[0],
|
|
trade=trade,
|
|
status=WorkItemStatus(row[10]),
|
|
attempts=int(row[11]) if row[11] is not None else 0,
|
|
last_outcome=OutboxOutcome(row[12]) if row[12] else None,
|
|
last_error=row[13],
|
|
created_at=row[14],
|
|
updated_at=row[15],
|
|
next_retry_at=row[16],
|
|
instance_key=row[19],
|
|
claim_expires_at=row[20],
|
|
)
|
|
|
|
|
|
class PgStrategyStateStore:
|
|
"""PostgreSQL slot-scoped strategy-instance map store."""
|
|
|
|
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
|
self._psycopg = _require_psycopg(psycopg_module)
|
|
self._dsn = dsn
|
|
|
|
def set(
|
|
self,
|
|
slot_id: UUID,
|
|
strategy_instance_id: str,
|
|
*,
|
|
expected_version: int,
|
|
data: str,
|
|
slot_active: bool,
|
|
) -> SetResult:
|
|
if len(data.encode("utf-8")) > _STATE_CAP_BYTES:
|
|
return SetResult(ok=False, reason="state_too_large")
|
|
now = datetime.now(tz=timezone.utc)
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
try:
|
|
with conn.cursor() as cur:
|
|
# Lock the lifecycle row before changing state. A close UPDATE
|
|
# and this write therefore have one serial order: a writer that
|
|
# follows a close observes INACTIVE and cannot recreate state.
|
|
cur.execute(
|
|
"SELECT status FROM daily_slot WHERE slot_id = %s::uuid FOR UPDATE",
|
|
(slot_id,),
|
|
)
|
|
slot = cur.fetchone()
|
|
if slot is None or slot[0] not in {"pending_buy", "holding", "pending_sell"}:
|
|
return SetResult(ok=False, reason="slot_inactive")
|
|
cur.execute(
|
|
"SELECT data FROM slot_strategy_state WHERE slot_id = %s::uuid",
|
|
(slot_id,),
|
|
)
|
|
state_row = cur.fetchone()
|
|
state_map = state_row[0] if state_row is not None else {}
|
|
entry = state_map.get(strategy_instance_id)
|
|
version = int(entry["version"]) if entry is not None else 0
|
|
if expected_version != version:
|
|
return SetResult(
|
|
ok=False,
|
|
reason="version_conflict",
|
|
version=version,
|
|
)
|
|
entry_json = "jsonb_build_object('version', %s::bigint, " \
|
|
"'data', %s::text, 'updated_at', %s::timestamptz)"
|
|
if state_row is None:
|
|
cur.execute(
|
|
"INSERT INTO slot_strategy_state (slot_id, data) VALUES ("
|
|
"%s::uuid, jsonb_build_object(%s::text, " + entry_json + "))",
|
|
(slot_id, strategy_instance_id, version + 1, data, now),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"UPDATE slot_strategy_state SET data = jsonb_set(data, "
|
|
"ARRAY[%s::text]::text[], " + entry_json + ", true) "
|
|
"WHERE slot_id = %s::uuid",
|
|
(strategy_instance_id, version + 1, data, now, slot_id),
|
|
)
|
|
conn.commit()
|
|
return SetResult(ok=True, version=version + 1)
|
|
except BaseException:
|
|
conn.rollback()
|
|
raise
|
|
|
|
def get_for_slots(
|
|
self, slot_ids: list[UUID]
|
|
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
|
if not slot_ids:
|
|
return {}
|
|
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT slot_id, data "
|
|
"FROM slot_strategy_state WHERE slot_id = ANY(%s)",
|
|
(list(slot_ids),),
|
|
)
|
|
for row in cur.fetchall():
|
|
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
|
|
for instance_id, entry in row[1].items():
|
|
out.setdefault(sid, {})[instance_id] = StrategyStateEntry(
|
|
version=int(entry["version"]),
|
|
data=entry["data"],
|
|
updated_at=datetime.fromisoformat(entry["updated_at"]),
|
|
)
|
|
return out
|