Deliver BAT-002: slot-core daemon + HTTP intent API + PostgreSQL

Implement REQ-005~009 (dcy-002 slice 1) as an onion stack on top of the
BAT-001 pure domain:
- application: IntentService (admission + single-writer transition +
  intent_id idempotency via durable intent_log)
- adapters/persistence: PostgreSQLSlotRepository + PostgreSQLIntentStore +
  trex-core-owned migrations (daily_slot per Appendix A, daily_symbol_entries,
  slot_transition_log, intent_log) + in-memory intent store
- adapters/api: FastAPI /v1 (healthz, intents/buy|sell|{id}, slots, capacity)
- runtime: trex-core-daemon entrypoint with lifespan migrate+preallocate

Verification (against local PostgreSQL 18): 74 passed, ruff clean, domain
import sweep shows zero web/db deps (NFR-1), 20-thread concurrency admits
exactly the idle count (NFR-2), idempotent resubmit causes no double
transition (NFR-3), EXPECTED_SLOT_FIELDS<->information_schema drift test
(realizes dcy-001 OQ-2's deferred DB-side check), and a daemon E2E smoke.

Align all four baseline docs to the new daemon/API/persistence layers.
BAT-002 -> release-candidate; REQ-005~009 -> Implemented. Full async outbox,
broker execution, safety floor, and strategy daemons remain deferred to a
follow-up Discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:46:08 +09:00
co-authored by Claude Opus 4.8
parent a44f9fddb7
commit a4e263c8e7
41 changed files with 1778 additions and 38 deletions
@@ -0,0 +1,45 @@
"""In-memory IntentStore — for API tests without PostgreSQL."""
from __future__ import annotations
import threading
from typing import Optional
from uuid import UUID
from trex_core.application.ports import IntentRecord
class _Rec:
def __init__(self, intent_id: str, intent_type: str) -> None:
self.intent_id = intent_id
self.intent_type = intent_type
self.status = "processing"
self.slot_id: Optional[UUID] = None
self.reason: Optional[str] = None
class InMemoryIntentStore:
"""Thread-safe in-memory intent dedup/audit store."""
def __init__(self) -> None:
self._rows: dict[str, _Rec] = {}
self._lock = threading.Lock()
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
with self._lock:
if intent_id in self._rows:
return False
self._rows[intent_id] = _Rec(intent_id, intent_type)
return True
def get(self, intent_id: str) -> Optional[IntentRecord]:
return self._rows.get(intent_id)
def finalize(
self, intent_id: str, status: str, slot_id: Optional[UUID], reason: Optional[str]
) -> None:
with self._lock:
rec = self._rows.get(intent_id)
if rec is not None:
rec.status = status
rec.slot_id = slot_id
rec.reason = reason
+31
View File
@@ -0,0 +1,31 @@
"""Apply slot-core SQL migrations in sorted order (REQ-006).
Idempotent: every migration uses CREATE TABLE IF NOT EXISTS / CREATE INDEX
IF NOT EXISTS, so re-running against an existing database is a no-op.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
_SQL_DIR = Path(__file__).resolve().parent / "sql"
def migration_files() -> list[Path]:
return sorted(_SQL_DIR.glob("*.sql"))
def apply_migrations(dsn: str, *, psycopg_module: Any = None) -> list[str]:
"""Apply all migrations to `dsn`. Returns the list of applied filenames."""
if psycopg_module is None:
import psycopg # type: ignore[import-not-found]
psycopg_module = psycopg
applied: list[str] = []
with psycopg_module.connect(dsn, autocommit=False) as conn:
for path in migration_files():
with conn.cursor() as cur:
cur.execute(path.read_text())
applied.append(path.name)
conn.commit()
return applied
@@ -0,0 +1,260 @@
"""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
@@ -0,0 +1,74 @@
-- BAT-002 / REQ-006: trex-core-owned slot-core schema.
--
-- Authored fresh for trex-core, keyed to the Slot column contract in
-- dcy-001 Appendix A (orbit-v2 DDL used only as reference). Scope: slice 1.
-- Excludes daily_slot_archive / retention prune (out of scope, dcy-002).
-- daily_slot — current lifecycle state (single-writer source of truth).
-- Columns mirror dcy-001 Appendix A (16 columns) and trex_core EXPECTED_SLOT_FIELDS.
CREATE TABLE IF NOT EXISTS daily_slot (
slot_id UUID PRIMARY KEY,
trading_date DATE NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('idle','pending_buy','holding','pending_sell','closed','error')
),
symbol TEXT,
qty NUMERIC(18, 4),
avg_price NUMERIC(18, 4),
entry_price NUMERIC(18, 4),
allocated_capital NUMERIC(18, 2),
order_id TEXT,
strategy_id TEXT,
broker_order_id TEXT,
portfolio_id TEXT,
sell_strategy_id TEXT,
forced_close_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
);
CREATE INDEX IF NOT EXISTS idx_daily_slot_trading_date_status
ON daily_slot (trading_date, status);
-- daily_symbol_entries — per-day re-entry counter (decision input).
CREATE TABLE IF NOT EXISTS daily_symbol_entries (
trading_date DATE NOT NULL,
symbol TEXT NOT NULL,
entry_count INTEGER NOT NULL DEFAULT 0,
last_entry_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
PRIMARY KEY (trading_date, symbol)
);
-- slot_transition_log — append-only audit of every transition.
CREATE TABLE IF NOT EXISTS slot_transition_log (
log_id BIGSERIAL PRIMARY KEY,
slot_id UUID NOT NULL,
from_state TEXT NOT NULL,
to_state TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
reason TEXT NOT NULL,
actor TEXT NOT NULL,
extra JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_slot_transition_log_slot_id_ts
ON slot_transition_log (slot_id, ts);
-- intent_log — durable idempotency store + audit for accepted intents
-- (dcy-002 OQ-1). intent_id UNIQUE is the dedup gate that prevents double
-- slot transitions on at-least-once retransmission. Precursor to a full
-- outbox (status column reserved); async consumer loop deferred to the
-- broker-execution Discovery.
CREATE TABLE IF NOT EXISTS intent_log (
intent_id TEXT PRIMARY KEY,
intent_type TEXT NOT NULL CHECK (intent_type IN ('buy','sell')),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'processing'
CHECK (status IN ('processing','admitted','rejected')),
slot_id UUID,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
);
CREATE INDEX IF NOT EXISTS idx_intent_log_status ON intent_log (status);