chore: plan for review blockers follow-up
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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);
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user