feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)

M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거),
마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성.
M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty,
미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지).
M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP),
repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드),
admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active),
daily_symbol_entries 코드 경로 제거.
M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/
--allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건.
M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract
자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합.

검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트
가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1):
reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→
매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green.
실 cutover 는 운영자 실행(REL 단계, D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:16:15 +09:00
co-authored by Claude Opus 4.8
parent cec34be5f4
commit 7d727824dc
67 changed files with 1584 additions and 1862 deletions
+17 -13
View File
@@ -1,10 +1,13 @@
"""Operational integrity checks — read-only (REQ-055 / dcy-011 FR-5, issue #11).
`find_ledger_gap_slots` detects `closed` slots that produced NO ledger_outbox
row (the silent-skip symptom): either `portfolio_id IS NULL` (skip at fill) or
no outbox row carries the slot's portfolio_id as `asset_id`. Read-only — no
schema change; `daily_slot` is durable so this is a permanent detector even
without an outbox marker row (dcy-010 OQ-4).
`find_ledger_gap_slots` detects sold slots that produced NO ledger_outbox row
(the silent-skip symptom). dcy-020 (BAT-030): the pool-model `closed` status no
longer exists — a sold slot returns to `inactive` and its occupancy is cleared —
so the detector keys off the append-only `slot_transition_log` (reason
`sell_filled` / `sell_partial_cancel`) instead of the slot row's status. A gap
is a sell fill whose slot has `portfolio_id IS NULL` (skip at fill) or whose
portfolio_id never appears as a ledger_outbox `asset_id`. Read-only — no schema
change; the transition log is durable so this is a permanent detector.
"""
from __future__ import annotations
@@ -14,20 +17,21 @@ from typing import Any, Optional
from trex_core.adapters.persistence.postgresql import _require_psycopg
_LEDGER_GAP_SQL = """
SELECT s.slot_id, s.trading_date, s.symbol, s.portfolio_id, s.broker_order_id
FROM daily_slot s
WHERE s.status = 'closed'
AND (%(td)s::date IS NULL OR s.trading_date = %(td)s)
SELECT DISTINCT s.slot_id, s.symbol, s.portfolio_id, s.broker_order_id, t.ts
FROM slot_transition_log t
JOIN daily_slot s ON s.slot_id = t.slot_id
WHERE t.reason IN ('sell_filled', 'sell_partial_cancel')
AND (%(td)s::date IS NULL OR (t.ts AT TIME ZONE 'UTC')::date = %(td)s)
AND (
s.portfolio_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM ledger_outbox o WHERE o.asset_id = s.portfolio_id
)
)
ORDER BY s.trading_date, s.slot_id
ORDER BY t.ts, s.slot_id
"""
_COLS = ("slot_id", "trading_date", "symbol", "portfolio_id", "broker_order_id")
_COLS = ("slot_id", "symbol", "portfolio_id", "broker_order_id", "sold_at")
def find_ledger_gap_slots(
@@ -36,9 +40,9 @@ def find_ledger_gap_slots(
trading_date: Optional[date] = None,
psycopg_module: Any = None,
) -> list[dict]:
"""Return closed slots with no corresponding ledger_outbox row (FR-5).
"""Return sell fills with no corresponding ledger_outbox row (FR-5).
Pass `trading_date` to scope to one day; omit for all days.
Pass `trading_date` to scope to one (UTC) day of sell fills; omit for all.
"""
psycopg = _require_psycopg(psycopg_module)
with psycopg.connect(dsn) as conn:
+54 -121
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from typing import Any, Iterator, Optional
from uuid import UUID, uuid4
@@ -27,10 +27,10 @@ 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, limit_price, "
"slot_id, status, symbol, qty, avg_price, limit_price, "
"allocated_capital, 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, entry_count, instance_key, retired_at"
"created_at, updated_at, instance_key, retired_at"
)
@@ -65,34 +65,12 @@ class PostgreSQLSlotRepository:
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]:
def list_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 trading_date = %s",
(trading_date,),
f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE instance_key = %s",
(instance_key,),
)
return [self._row_to_slot(r) for r in cur.fetchall()]
@@ -105,32 +83,6 @@ class PostgreSQLSlotRepository:
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 find_allocatable_slot(self, trading_date: date) -> Optional[Slot]:
# dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc
# → updated_at asc → slot_id (deterministic, OQ-2). Concurrency on the
# last candidate handled by the caller (advisory lock + retry, NFR-1).
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 IN ('idle','closed') "
"ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1",
(trading_date,),
)
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:
@@ -165,7 +117,7 @@ class PostgreSQLSlotRepository:
return [self._row_to_slot(r) for r in cur.fetchall()]
def reconcile_universe(
self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str],
self, instance_key: str, universe_portfolio_ids: set[str],
*, allow_retire: bool = True,
) -> dict[str, int]:
now = datetime.now(tz=timezone.utc)
@@ -184,11 +136,11 @@ class PostgreSQLSlotRepository:
continue
cur.execute(
"INSERT INTO daily_slot "
"(slot_id, trading_date, status, instance_key, portfolio_id, "
"created_at, updated_at) VALUES (%s, %s, 'idle', %s, %s, %s, %s) "
"(slot_id, status, instance_key, portfolio_id, "
"created_at, updated_at) VALUES (%s, 'inactive', %s, %s, %s, %s) "
"ON CONFLICT (instance_key, portfolio_id) "
"WHERE portfolio_id IS NOT NULL DO NOTHING",
(uuid4(), trading_date, instance_key, pid, now, now),
(uuid4(), instance_key, pid, now, now),
)
created += cur.rowcount
# retire / tombstone assets that left the universe (skipped under
@@ -217,7 +169,7 @@ class PostgreSQLSlotRepository:
"limit_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, stop_loss_price=%s, take_profit_price=%s, "
"entry_count=%s, instance_key=%s, retired_at=%s, "
"instance_key=%s, retired_at=%s, "
"updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
@@ -225,7 +177,7 @@ class PostgreSQLSlotRepository:
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.entry_count, slot.instance_key, slot.retired_at,
slot.instance_key, slot.retired_at,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
),
)
@@ -248,43 +200,23 @@ class PostgreSQLSlotRepository:
)
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
# --- order execution queue (BAT-004 / REQ-013, option A) ---
def find_pending_for_dispatch(self) -> Optional[Slot]:
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 status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL "
"ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED"
"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),
)
row = cur.fetchone()
return self._row_to_slot(row) if row else None
return [self._row_to_slot(r) for r in cur.fetchall()]
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
@@ -298,12 +230,14 @@ class PostgreSQLSlotRepository:
conn.commit()
return ok
def list_dispatched_pending(self) -> list[Slot]:
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 status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL"
"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()]
@@ -316,12 +250,13 @@ class PostgreSQLSlotRepository:
)
conn.commit()
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
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 trading_date = %s GROUP BY status",
(trading_date,),
"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()}
@@ -330,33 +265,31 @@ class PostgreSQLSlotRepository:
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) trading_date(1) status(2) symbol(3) qty(4)
# avg_price(5) limit_price(6) allocated_capital(7) order_id(8)
# strategy_id(9) broker_order_id(10) portfolio_id(11) sell_strategy_id(12)
# forced_close_at(13) stop_loss_price(14) take_profit_price(15)
# created_at(16) updated_at(17) entry_count(18)
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
# limit_price(5) allocated_capital(6) order_id(7) strategy_id(8)
# broker_order_id(9) portfolio_id(10) sell_strategy_id(11)
# forced_close_at(12) stop_loss_price(13) take_profit_price(14)
# created_at(15) updated_at(16) instance_key(17) retired_at(18)
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]),
limit_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],
stop_loss_price=d(row[14]),
take_profit_price=d(row[15]),
created_at=row[16],
updated_at=row[17],
entry_count=int(row[18]) if row[18] is not None else 0,
instance_key=row[19] if row[19] is not None else "default",
retired_at=row[20],
status=SlotState(row[1]),
symbol=row[2],
qty=f(row[3]),
avg_price=f(row[4]),
limit_price=f(row[5]),
allocated_capital=f(row[6]),
order_id=row[7],
strategy_id=row[8],
broker_order_id=row[9],
portfolio_id=row[10],
sell_strategy_id=row[11],
forced_close_at=row[12],
stop_loss_price=d(row[13]),
take_profit_price=d(row[14]),
created_at=row[15],
updated_at=row[16],
instance_key=row[17] if row[17] is not None else "default",
retired_at=row[18],
)
@@ -27,8 +27,11 @@ CREATE TABLE IF NOT EXISTS daily_slot (
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);
-- BAT-030 / dcy-020: the (trading_date, status) index is superseded by a
-- status index — trading_date is dropped at migration 011 and this file is
-- re-applied on every boot, so it must not reference the dropped column.
CREATE INDEX IF NOT EXISTS idx_daily_slot_status
ON daily_slot (status);
-- daily_symbol_entries — per-day re-entry counter (decision input).
CREATE TABLE IF NOT EXISTS daily_symbol_entries (
@@ -1,7 +1,6 @@
-- BAT-025 / dcy-019 / REQ-006: slot-scoped entry_count on daily_slot.
-- Tracks how many times a slot has entered pending_buy (lifetime, per trading
-- day). Drives the buy-admission selector's fairness ordering (idle ∪ closed,
-- least-entered first) and enables closed-slot reallocation (issue #18).
-- Distinct from daily_symbol_entries.entry_count (per-symbol cap, unchanged).
-- Additive + idempotent: existing rows default to 0 (no prior entries counted).
ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0;
-- BAT-025 / dcy-019 / REQ-006: added slot-scoped entry_count to daily_slot.
-- BAT-030 / dcy-020 / REQ-056: entry_count was DROPPED at the asset-centric
-- cutover (migration 011). Because the migration set is re-applied on every
-- boot, the original ADD COLUMN here would resurrect the column each run —
-- so this file is intentionally a no-op now (kept for numbering continuity).
SELECT 1;
@@ -0,0 +1,11 @@
-- BAT-030 / dcy-020 / REQ-057: literal `inactive` 전면화 (D2).
-- The pool-model `idle` (never-bought) and `closed` (sold, flat) statuses are
-- collapsed into the asset-centric `inactive`. Rewrites any remaining rows and
-- swaps the status CHECK constraint. Idempotent: the UPDATE matches nothing on
-- re-run and the constraint is dropped/re-added by name each time.
UPDATE daily_slot SET status = 'inactive' WHERE status IN ('idle', 'closed');
ALTER TABLE daily_slot DROP CONSTRAINT IF EXISTS daily_slot_status_check;
ALTER TABLE daily_slot ADD CONSTRAINT daily_slot_status_check CHECK (
status IN ('inactive','pending_buy','holding','pending_sell','error')
);
@@ -0,0 +1,9 @@
-- BAT-030 / dcy-020 / REQ-056: drop pool-model columns (D2, 비가역).
-- The permanent asset slot is keyed by (instance_key, portfolio_id) and lives
-- across trading days — `trading_date` (day-scoped pool identity) and
-- `entry_count` (pool fairness ordering) no longer apply. Time attributes live
-- on slot_transition_log / ledger rows. DROP COLUMN also removes any dependent
-- index. Idempotent via IF EXISTS. Rollback = restore from the pre-cutover
-- backup (see BAT-030 design §4).
ALTER TABLE daily_slot DROP COLUMN IF EXISTS entry_count;
ALTER TABLE daily_slot DROP COLUMN IF EXISTS trading_date;