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:
@@ -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;
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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)."""
|
||||
...
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user