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:
@@ -0,0 +1,114 @@
|
||||
"""FastAPI app factory for the slot-core daemon (REQ-005/007/008).
|
||||
|
||||
`create_app` wires the HTTP /v1 surface to injected services so the same app
|
||||
runs against the in-memory adapters (tests) or PostgreSQL adapters (daemon).
|
||||
All slot writes go through IntentService → SlotService (single writer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
|
||||
from trex_core.adapters.api.schemas import (
|
||||
BuyIntentRequest,
|
||||
CapacityResponse,
|
||||
HealthResponse,
|
||||
IntentResponse,
|
||||
SellIntentRequest,
|
||||
SlotSnapshot,
|
||||
)
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotRepository, SlotService
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
slot_service: SlotService,
|
||||
slot_repo: SlotRepository,
|
||||
intent_service: IntentService,
|
||||
default_total_slots: int,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="trex-core slot daemon", version="0.1.0")
|
||||
|
||||
@app.get("/v1/healthz", response_model=HealthResponse)
|
||||
def healthz() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
@app.post("/v1/intents/buy", response_model=IntentResponse)
|
||||
def submit_buy(req: BuyIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id=req.intent_id,
|
||||
trading_date=req.trading_date,
|
||||
symbol=req.symbol,
|
||||
qty=req.qty,
|
||||
allocated_capital=req.allocated_capital,
|
||||
entry_price=req.entry_price,
|
||||
strategy_id=req.strategy_id,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
portfolio_id=req.portfolio_id,
|
||||
forced_close_at=req.forced_close_at,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.post("/v1/intents/sell", response_model=IntentResponse)
|
||||
def submit_sell(req: SellIntentRequest) -> IntentResponse:
|
||||
d = intent_service.submit_sell(
|
||||
SellIntent(
|
||||
intent_id=req.intent_id,
|
||||
slot_id=req.slot_id,
|
||||
reason=req.reason,
|
||||
sell_strategy_id=req.sell_strategy_id,
|
||||
)
|
||||
)
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/intents/{intent_id}", response_model=IntentResponse)
|
||||
def get_intent(intent_id: str) -> IntentResponse:
|
||||
d = intent_service.lookup(intent_id)
|
||||
if d is None:
|
||||
raise HTTPException(status_code=404, detail="intent not found")
|
||||
return IntentResponse(intent_id=d.intent_id, status=d.status, slot_id=d.slot_id, reason=d.reason)
|
||||
|
||||
@app.get("/v1/slots", response_model=list[SlotSnapshot])
|
||||
def list_slots(
|
||||
status: Optional[str] = Query(default=None),
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
) -> list[SlotSnapshot]:
|
||||
day = trading_date or _today()
|
||||
slots = slot_repo.list_slots(day)
|
||||
if status is not None:
|
||||
slots = [s for s in slots if s.status.value == status]
|
||||
return [
|
||||
SlotSnapshot(
|
||||
slot_id=s.slot_id, trading_date=s.trading_date, status=s.status.value,
|
||||
symbol=s.symbol, qty=s.qty, avg_price=s.avg_price, entry_price=s.entry_price,
|
||||
allocated_capital=s.allocated_capital, strategy_id=s.strategy_id,
|
||||
sell_strategy_id=s.sell_strategy_id, forced_close_at=s.forced_close_at,
|
||||
)
|
||||
for s in slots
|
||||
]
|
||||
|
||||
@app.get("/v1/capacity", response_model=CapacityResponse)
|
||||
def capacity(
|
||||
trading_date: Optional[date_cls] = Query(default=None),
|
||||
total_slots: Optional[int] = Query(default=None),
|
||||
) -> CapacityResponse:
|
||||
day = trading_date or _today()
|
||||
cap = slot_service.compute_slot_capacity(day, total_slots or default_total_slots)
|
||||
return CapacityResponse(
|
||||
idle_count=cap.idle_count, holding_count=cap.holding_count,
|
||||
pending_count=cap.pending_count, total_slots=cap.total_slots,
|
||||
scarcity_ratio=cap.scarcity_ratio,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _today() -> date_cls:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(tz=timezone.utc).date()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic request/response schemas for the /v1 HTTP API (REQ-007/008)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BuyIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
trading_date: date
|
||||
symbol: str
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
portfolio_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class SellIntentRequest(BaseModel):
|
||||
intent_id: str
|
||||
slot_id: UUID
|
||||
reason: str = "discretionary_sell"
|
||||
sell_strategy_id: Optional[str] = None
|
||||
|
||||
|
||||
class IntentResponse(BaseModel):
|
||||
intent_id: str
|
||||
status: str
|
||||
slot_id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class SlotSnapshot(BaseModel):
|
||||
slot_id: UUID
|
||||
trading_date: date
|
||||
status: str
|
||||
symbol: Optional[str] = None
|
||||
qty: Optional[float] = None
|
||||
avg_price: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CapacityResponse(BaseModel):
|
||||
idle_count: int
|
||||
holding_count: int
|
||||
pending_count: int
|
||||
total_slots: int
|
||||
scarcity_ratio: float
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Intent application service (REQ-007).
|
||||
|
||||
Turns a buy/sell intent submitted over HTTP into a single-writer slot
|
||||
transition, with intent_id idempotency backed by a durable intent_log.
|
||||
|
||||
Idempotency model (dcy-002 OQ-1): `intent_store.try_insert` is the dedup
|
||||
gate. On a duplicate intent_id it returns the prior decision without
|
||||
re-driving any transition (NFR-3). Slot transitions go exclusively through
|
||||
SlotService (advisory-lock serialized) so the single-writer invariant
|
||||
holds (NFR-2). Concurrency on the last idle slot is resolved by retrying
|
||||
on SlotStateMismatchError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.application.ports import IntentStore
|
||||
from trex_core.domain.slot import (
|
||||
SlotRepository,
|
||||
SlotService,
|
||||
SlotState,
|
||||
SlotStateMismatchError,
|
||||
)
|
||||
|
||||
_MAX_IDLE_RETRY = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentDecision:
|
||||
"""Result of submitting an intent."""
|
||||
|
||||
intent_id: str
|
||||
status: str # 'admitted' | 'rejected'
|
||||
slot_id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
@property
|
||||
def admitted(self) -> bool:
|
||||
return self.status == "admitted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuyIntent:
|
||||
intent_id: str
|
||||
trading_date: date
|
||||
symbol: str
|
||||
qty: Optional[float] = None
|
||||
allocated_capital: Optional[float] = None
|
||||
entry_price: Optional[float] = None
|
||||
strategy_id: Optional[str] = None
|
||||
sell_strategy_id: Optional[str] = None
|
||||
portfolio_id: Optional[str] = None
|
||||
forced_close_at: Optional[datetime] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SellIntent:
|
||||
intent_id: str
|
||||
slot_id: UUID
|
||||
reason: str = "discretionary_sell"
|
||||
sell_strategy_id: Optional[str] = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class IntentService:
|
||||
"""Admission + slot transition for buy/sell intents (single writer)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
slot_service: SlotService,
|
||||
slot_repo: SlotRepository,
|
||||
intent_store: IntentStore,
|
||||
) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = slot_repo
|
||||
self._intents = intent_store
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
|
||||
"""Return the recorded decision for an intent_id, or None (re-query)."""
|
||||
rec = self._intents.get(intent_id)
|
||||
if rec is None:
|
||||
return None
|
||||
return IntentDecision(rec.intent_id, rec.status, rec.slot_id, rec.reason)
|
||||
|
||||
def submit_buy(self, intent: BuyIntent) -> IntentDecision:
|
||||
payload = {
|
||||
"trading_date": intent.trading_date.isoformat(),
|
||||
"symbol": intent.symbol,
|
||||
"qty": intent.qty,
|
||||
"allocated_capital": intent.allocated_capital,
|
||||
"entry_price": intent.entry_price,
|
||||
"strategy_id": intent.strategy_id,
|
||||
"sell_strategy_id": intent.sell_strategy_id,
|
||||
"portfolio_id": intent.portfolio_id,
|
||||
}
|
||||
prior = self._dedup(intent.intent_id, "buy", payload)
|
||||
if prior is not None:
|
||||
return prior
|
||||
|
||||
decision = self._admit_buy(intent)
|
||||
self._intents.finalize(
|
||||
intent.intent_id, decision.status, decision.slot_id, decision.reason
|
||||
)
|
||||
return decision
|
||||
|
||||
def submit_sell(self, intent: SellIntent) -> IntentDecision:
|
||||
payload = {
|
||||
"slot_id": str(intent.slot_id),
|
||||
"reason": intent.reason,
|
||||
"sell_strategy_id": intent.sell_strategy_id,
|
||||
}
|
||||
prior = self._dedup(intent.intent_id, "sell", payload)
|
||||
if prior is not None:
|
||||
return prior
|
||||
|
||||
decision = self._admit_sell(intent)
|
||||
self._intents.finalize(
|
||||
intent.intent_id, decision.status, decision.slot_id, decision.reason
|
||||
)
|
||||
return decision
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _dedup(self, intent_id: str, intent_type: str, payload: dict) -> Optional[IntentDecision]:
|
||||
"""Return prior decision if intent_id already seen; else None (and reserve it)."""
|
||||
inserted = self._intents.try_insert(intent_id, intent_type, payload)
|
||||
if inserted:
|
||||
return None
|
||||
rec = self._intents.get(intent_id)
|
||||
if rec is None: # extremely unlikely race; treat as not-yet-decided
|
||||
return IntentDecision(intent_id, "rejected", None, "duplicate_in_flight")
|
||||
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
|
||||
|
||||
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
|
||||
# Retry on the last-idle-slot race: another intent may claim the slot
|
||||
# between our find and transit (SlotStateMismatchError).
|
||||
for _ in range(_MAX_IDLE_RETRY):
|
||||
slot = self._repo.find_idle_slot(intent.trading_date)
|
||||
if slot is None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "no_idle_slot")
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.IDLE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="buy_intent",
|
||||
actor="intent_api",
|
||||
symbol=intent.symbol,
|
||||
qty=intent.qty,
|
||||
allocated_capital=intent.allocated_capital,
|
||||
entry_price=intent.entry_price,
|
||||
strategy_id=intent.strategy_id,
|
||||
sell_strategy_id=intent.sell_strategy_id,
|
||||
portfolio_id=intent.portfolio_id,
|
||||
forced_close_at=intent.forced_close_at,
|
||||
extra={"intent_id": intent.intent_id, "symbol": intent.symbol},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
continue # slot was claimed concurrently; try another idle slot
|
||||
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "idle_slot_contended")
|
||||
|
||||
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
|
||||
slot = self._repo.fetch_slot(intent.slot_id)
|
||||
if slot is None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found")
|
||||
if slot.status is not SlotState.HOLDING:
|
||||
return IntentDecision(
|
||||
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
|
||||
)
|
||||
try:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.HOLDING,
|
||||
SlotState.PENDING_SELL,
|
||||
reason=intent.reason,
|
||||
actor="intent_api",
|
||||
sell_strategy_id=intent.sell_strategy_id,
|
||||
extra={"intent_id": intent.intent_id},
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "slot_state_changed")
|
||||
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Application ports (REQ-007). The intent dedup/audit store contract.
|
||||
|
||||
Kept in the application layer (not domain) so the pure slot domain stays
|
||||
unaware of the intent concept. Concrete implementations live in adapters.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class IntentStore(Protocol):
|
||||
"""Durable idempotency + audit store for accepted intents (dcy-002 OQ-1).
|
||||
|
||||
`try_insert` is the dedup gate: it returns False if `intent_id` already
|
||||
exists, which prevents a duplicate intent from re-driving a slot
|
||||
transition (NFR-3). `finalize` records the decision after processing.
|
||||
"""
|
||||
|
||||
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
|
||||
"""Insert a new intent row in 'processing' state.
|
||||
|
||||
Returns True if newly inserted, False if `intent_id` already exists.
|
||||
"""
|
||||
...
|
||||
|
||||
def get(self, intent_id: str) -> Optional["IntentRecord"]:
|
||||
"""Return the stored intent record, or None."""
|
||||
...
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
intent_id: str,
|
||||
status: str,
|
||||
slot_id: Optional[UUID],
|
||||
reason: Optional[str],
|
||||
) -> None:
|
||||
"""Update an intent row with its final decision (admitted/rejected)."""
|
||||
...
|
||||
|
||||
|
||||
class IntentRecord:
|
||||
"""Snapshot of an intent_log row (duck-typed; see adapters)."""
|
||||
|
||||
intent_id: str
|
||||
intent_type: str
|
||||
status: str
|
||||
slot_id: Optional[UUID]
|
||||
reason: Optional[str]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Daemon configuration resolution (REQ-005)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DaemonConfig:
|
||||
dsn: str
|
||||
total_slots: int
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "DaemonConfig":
|
||||
dsn = os.environ.get("TREX_POSTGRESQL_DSN")
|
||||
if not dsn:
|
||||
raise RuntimeError(
|
||||
"TREX_POSTGRESQL_DSN is required to start the slot-core daemon."
|
||||
)
|
||||
return DaemonConfig(
|
||||
dsn=dsn,
|
||||
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
||||
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("TREX_API_PORT", "8080")),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Slot-core daemon entrypoint (REQ-005).
|
||||
|
||||
Builds the FastAPI app wired to PostgreSQL adapters, applies migrations and
|
||||
preallocates the trading day's slots on startup (lifecycle, not a public
|
||||
endpoint), then serves via uvicorn. Console script: `trex-core-daemon`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from trex_core.adapters.api.app import create_app
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PostgreSQLIntentStore,
|
||||
PostgreSQLSlotRepository,
|
||||
)
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
|
||||
|
||||
def build_app(config: DaemonConfig) -> FastAPI:
|
||||
"""Construct the daemon app with PostgreSQL adapters + startup lifecycle."""
|
||||
slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
slot_service = SlotService(slot_repo)
|
||||
intent_service = IntentService(slot_service, slot_repo, intent_store)
|
||||
|
||||
base = create_app(
|
||||
slot_service=slot_service,
|
||||
slot_repo=slot_repo,
|
||||
intent_service=intent_service,
|
||||
default_total_slots=config.total_slots,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
# Startup: migrate schema, preallocate today's slots (idempotent).
|
||||
apply_migrations(config.dsn)
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
slot_service.preallocate_daily_slots(today, config.total_slots)
|
||||
yield
|
||||
# Shutdown: connection-per-call adapters hold no pooled state to close.
|
||||
|
||||
base.router.lifespan_context = lifespan
|
||||
return base
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - process entrypoint
|
||||
import uvicorn
|
||||
|
||||
config = DaemonConfig.from_env()
|
||||
app = build_app(config)
|
||||
uvicorn.run(app, host=config.host, port=config.port)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Reference in New Issue
Block a user