feat(slot): slot entry_count + closed-slot reallocation (dcy-019, BAT-025) + REL-005

dcy-019/issue #18: slot 자원을 진입 횟수 기준으로 고르게 재사용하고 idle 소진 시
closed 슬롯을 직접 재할당한다. 거버넌스는 전부 change-req(신규 REQ 없음).

- 도메인(REQ-002): Slot.entry_count(기본 0) + EXPECTED_SLOT_FIELDS, 상태 머신
  closed→pending_buy 신규 전이(base 8→9, ALLOWED_TRANSITIONS 14→15),
  _apply_transition_fields closed→pending_buy clean-slate + →pending_buy 시 +1
- 영속화(REQ-006): 멱등 마이그레이션 008(entry_count), flat 매핑,
  find_allocatable_slot(idle∪closed, entry_count→updated_at→slot_id) + in-memory 동등
- admission(REQ-007): _admit_buy find_idle_slot→find_allocatable_slot + from_state=slot.status
- 테스트(REQ-003): 전이 카운트/계약 가드 정합 + test_slot_entry_count.py / test_buy_admission_reallocation.py
- 문서(REQ-009): data-model.md / runtime-flows.md 정합
- 기존 daily_symbol_entries(symbol-scoped) 분리·미변경

BAT-025(release-candidate) delivery + change-req REQ-002/003/006/007/009(CHG-20260609-01).
REL-005(confirmed, app-service): BAT-024+BAT-025 묶음. operator-facing run-the-app
스모크로 enter --forced-close-at(BAT-024)·closed 재할당(BAT-025) 실 데몬 검증
(실 PG 마이그레이션 008 round-trip 포함).

검증: 단위 366 passed / 35 skipped(KIS sandbox 1건 deselect — live .env 환경 의존), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 14:49:22 +09:00
co-authored by Claude Opus 4.8
parent 941d324899
commit 6e8e79660c
28 changed files with 912 additions and 26 deletions
+20 -3
View File
@@ -30,7 +30,7 @@ _SLOT_COLUMNS = (
"slot_id, trading_date, 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"
"created_at, updated_at, entry_count"
)
@@ -116,6 +116,21 @@ class PostgreSQLSlotRepository:
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
def update_slot(self, slot: Slot) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
@@ -124,13 +139,14 @@ 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, "
"updated_at=%s WHERE slot_id=%s",
"entry_count=%s, updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
slot.limit_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.stop_loss_price, slot.take_profit_price,
slot.entry_count,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
),
)
@@ -239,7 +255,7 @@ class PostgreSQLSlotRepository:
# 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)
# created_at(16) updated_at(17) entry_count(18)
return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
trading_date=row[1],
@@ -259,6 +275,7 @@ class PostgreSQLSlotRepository:
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,
)
@@ -0,0 +1,7 @@
-- 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;