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;
+7 -3
View File
@@ -163,16 +163,20 @@ class IntentService:
elif not symbol:
# No resolver wired and no symbol carried — cannot determine the order target.
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
# Retry on the last-idle-slot race: another intent may claim the slot
# Retry on the last-candidate race: another intent may claim the slot
# between our find and transit (SlotStateMismatchError).
# dcy-019 / REQ-007: admission candidate is idle ∪ closed (closed-slot
# reallocation), ordered by entry_count asc → updated_at asc → slot_id.
# The candidate may already be CLOSED, so the transit's from_state must
# follow the slot's current status rather than a hardcoded IDLE.
for _ in range(_MAX_IDLE_RETRY):
slot = self._repo.find_idle_slot(intent.trading_date)
slot = self._repo.find_allocatable_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,
slot.status,
SlotState.PENDING_BUY,
reason="buy_intent",
actor="intent_api",
@@ -76,6 +76,25 @@ class InMemorySlotRepository:
return s
return 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).
candidates = [
s for s in self._slots.values()
if s.trading_date == trading_date
and s.status in (SlotState.IDLE, SlotState.CLOSED)
]
if not candidates:
return None
return min(
candidates,
key=lambda s: (
s.entry_count,
s.updated_at or datetime.min.replace(tzinfo=timezone.utc),
str(s.slot_id),
),
)
def update_slot(self, slot: Slot) -> None:
if slot.slot_id not in self._slots:
raise SlotNotFoundError(f"slot not found: {slot.slot_id}")
+6
View File
@@ -39,6 +39,11 @@ class Slot:
forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage)
stop_loss_price: Optional[Decimal] = None # REQ-035 / BAT-010
take_profit_price: Optional[Decimal] = None # REQ-035 / BAT-010
# dcy-019 / REQ-002 (BAT-025): slot-scoped cumulative entry count. +1 on each
# allocation transition (idle/closed → pending_buy); drives admission slot
# selection (lowest entry_count first). Distinct from the symbol-scoped
# daily_symbol_entries counter (REQ-002 AC-3) — different key/purpose.
entry_count: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -64,6 +69,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"forced_close_at",
"stop_loss_price", # REQ-035
"take_profit_price", # REQ-035
"entry_count", # dcy-019 / REQ-002 (BAT-025)
"created_at",
"updated_at",
})
+7
View File
@@ -52,6 +52,13 @@ class SlotRepository(Protocol):
"""Return any one idle slot (None if exhausted)."""
...
def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]:
"""Return the best buy-admission candidate among idle ∪ closed slots
(dcy-019 / REQ-006), ordered by `entry_count` asc → `updated_at` asc →
`slot_id`. None if neither idle nor closed slots remain. Concurrency on
the last candidate is handled by the caller (advisory lock + retry)."""
...
def update_slot(self, slot: Slot) -> None:
"""Persist slot row (UPDATE by slot_id)."""
...
+18
View File
@@ -201,6 +201,19 @@ class SlotService:
updated_at=datetime.now(tz=timezone.utc),
)
# dcy-019 / REQ-002 (BAT-025): closed-slot direct reallocation. A closed
# slot still carries the prior cycle's occupancy; start from a clean
# slate so a previous position/broker handle never leaks into the new
# entry. entry_count is intentionally preserved (slot-scoped cumulative).
if to_state is SlotState.PENDING_BUY and slot.status is SlotState.CLOSED:
slot = replace(
slot,
symbol=None, qty=None, avg_price=None, limit_price=None,
allocated_capital=None, order_id=None, strategy_id=None,
broker_order_id=None, portfolio_id=None, sell_strategy_id=None,
forced_close_at=None, stop_loss_price=None, take_profit_price=None,
)
# limit_price lifecycle (dcy-012 FR-1): set only on entering a pending
# state (the order's limit); cleared on entering HOLDING/CLOSED.
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}:
@@ -208,6 +221,10 @@ class SlotService:
else: # HOLDING / CLOSED — pending order limit no longer applies.
new_limit_price = None
# dcy-019 / REQ-002: +1 on each allocation transition (→pending_buy),
# for both idle→pending_buy and closed→pending_buy (FR-4).
new_entry_count = slot.entry_count + (1 if to_state is SlotState.PENDING_BUY else 0)
# Generic update: caller-supplied fields override; None means "keep".
return replace(
slot,
@@ -241,6 +258,7 @@ class SlotService:
take_profit_price=(
take_profit_price if take_profit_price is not None else slot.take_profit_price
),
entry_count=new_entry_count, # dcy-019 / REQ-002
updated_at=datetime.now(tz=timezone.utc),
)
+6 -4
View File
@@ -1,6 +1,7 @@
"""Slot state machine: 6 states + 8 allowed transitions (REQ-001, REQ-002).
"""Slot state machine: 6 states + 9 base allowed transitions (REQ-001, REQ-002).
The transitions match dcy-001 FR-2 and BAT-001 design.
The transitions match dcy-001 FR-2 and BAT-001 design, extended by dcy-019 with
`closed -> pending_buy` (closed-slot direct reallocation, BAT-025).
"""
from __future__ import annotations
@@ -24,8 +25,8 @@ class SlotState(str, Enum):
ERROR = "error"
# 8 allowed transitions per dcy-001 FR-2. Any pair not in this set must be
# rejected by SlotService.
# 9 base allowed transitions (dcy-001 FR-2 + dcy-019). Any pair not in this set
# must be rejected by SlotService.
# Note: `* -> error` is represented as transitions from every non-error state.
_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
@@ -36,6 +37,7 @@ _BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
(SlotState.PENDING_SELL, SlotState.CLOSED), # 5. sell filled
(SlotState.PENDING_SELL, SlotState.HOLDING), # 6. sell rejected/rollback
(SlotState.CLOSED, SlotState.IDLE), # 7. recovery (with daily_symbol_entries +1)
(SlotState.CLOSED, SlotState.PENDING_BUY), # 9. closed-slot direct reallocation (dcy-019/BAT-025)
}
)