feat(slot,intent): slot strategy-state 저장소 (dcy-028) + sell retryable 플래그 (issue #27)

BAT-042 (dcy-028 / issue #26 C-1/C-2/C-2b/C-3, REQ-079~082 + REQ-073/074 CHG):
- slot_strategy_state 별도 테이블 + StrategyStateStore(in-memory+PG) + occupancy_seq·version CAS + opaque UTF-8 data + 2단 size cap (REQ-079)
- trex.slots.strategy_state.set write RPC — reject reason 집합, 멱등 (REQ-080)
- Slot.occupancy_seq 필드 + inactive→pending_buy bump + →INACTIVE close 삭제 훅 → 점유 전환 ABA 구조적 차단 (REQ-081)
- slots.req 응답 행에 strategy_state 맵(batched 조립)·occupancy_seq additive (REQ-073 AC-7/REQ-074 AC-6)
- 마이그레이션 014(occupancy_seq)/015(slot_strategy_state), baseline 문서 정합 (REQ-082)

BAT-043 (issue #27 / REQ-007 CHG):
- rejected SellIntent 응답에 additive retryable: bool (core 소유 reason→retryable 매핑, 미인식 기본 true, bad_request* false) — 손절매 재시도 안전 경로 decoupling

검증: 487 passed, 47 skipped (신규 41: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5, retryable 8, PG 게이트), ruff clean.
REQ-079·080·081·082·073·074·007 → Implemented. issue #27 close. PG 경로 실 DB 검증은 release 위임.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 19:27:57 +09:00
co-authored by Claude Opus 4.8
parent bfadd0d5c8
commit e0d4e71304
40 changed files with 1941 additions and 14 deletions
+6
View File
@@ -51,6 +51,11 @@ class Slot:
# slippage_pct: effective limit = entry_price * (1 ± pct/100); None = no band.
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
# dcy-028 / REQ-081 (BAT-042): monotonic occupancy counter. Incremented when
# the slot begins a new occupancy (inactive→pending_buy), stable across one
# holding episode. Used by the strategy-state CAS to reject writes computed
# against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1).
occupancy_seq: int = 0
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -78,6 +83,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"retired_at", # dcy-020 / REQ-056 (BAT-027)
"timeout_sec", # dcy-022 / REQ-068 (BAT-032)
"slippage_pct", # dcy-022 / REQ-068 (BAT-032)
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
})
+24 -1
View File
@@ -39,12 +39,21 @@ _UNSET: Any = object()
class SlotService:
"""Slot lifecycle service. Race-free transitions via advisory lock + transaction."""
def __init__(self, repository: SlotRepository, instance_key: str = "default") -> None:
def __init__(
self,
repository: SlotRepository,
instance_key: str = "default",
*,
strategy_state_store: Any = None,
) -> None:
self._repo = repository
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
# does not serialize transitions across instances (NFR-1).
self._instance_key = instance_key
self._lock_id = slot_advisory_lock_id(instance_key)
# dcy-028/REQ-081 (C-3): when injected, all strategy_state for a slot is
# deleted on close (→INACTIVE). None keeps legacy behaviour (no store).
self._strategy_state_store = strategy_state_store
@property
def instance_key(self) -> str:
@@ -138,6 +147,11 @@ class SlotService:
extra=extra or {},
)
)
# dcy-028/REQ-081 (C-3): close deletes the slot's strategy_state so a
# reused slot starts empty. Covers every close path (strategy sell,
# safety-sell, forced-close, operator) since all funnel to →INACTIVE.
if to_state is SlotState.INACTIVE and self._strategy_state_store is not None:
self._strategy_state_store.delete_all(slot_id)
return updated
@staticmethod
@@ -202,6 +216,14 @@ class SlotService:
new_timeout_sec = None
new_slippage_pct = None
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
# occupancy (inactive→pending_buy). Stable across the rest of the
# holding episode; the INACTIVE branch above preserves it (monotonic).
if slot.status is SlotState.INACTIVE and to_state is SlotState.PENDING_BUY:
new_occupancy_seq = slot.occupancy_seq + 1
else:
new_occupancy_seq = slot.occupancy_seq
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4).
# Entering PENDING_SELL takes the caller qty AS-IS so a full sell
# (qty=None) records a "sell all" sentinel rather than the stale held
@@ -244,6 +266,7 @@ class SlotService:
),
timeout_sec=new_timeout_sec,
slippage_pct=new_slippage_pct,
occupancy_seq=new_occupancy_seq,
updated_at=datetime.now(tz=timezone.utc),
)