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
@@ -0,0 +1,103 @@
"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01).
Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable
via the core-owned table (unrecognized → True, safety default; bad_request → False).
admitted / processing / buy responses must NOT carry the field.
"""
from __future__ import annotations
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import (
_sell_reject_retryable,
make_intent_rpc_handler,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST, max_active_slots=5)
repo.reconcile_universe(INST, {"PF-1": "SYM"})
handler = make_intent_rpc_handler(intents)
slot = repo.list_slots(INST)[0]
return repo, svc, intents, handler, slot
def _sell(handler, slot_id, intent_id="s1"):
return handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id),
}, correlation_id=intent_id))
class TestRetryableMapping:
def test_each_reason_maps(self):
assert _sell_reject_retryable("slot_state_changed") is True
assert _sell_reject_retryable("slot_not_holding:pending_buy") is True
assert _sell_reject_retryable("duplicate_in_flight") is True
assert _sell_reject_retryable("slot_not_found") is False
assert _sell_reject_retryable("slot_not_holding:inactive") is False
assert _sell_reject_retryable("slot_not_holding:pending_sell") is False
assert _sell_reject_retryable("slot_not_holding:error") is False
def test_unrecognized_defaults_true(self):
assert _sell_reject_retryable("some_future_reason") is True
assert _sell_reject_retryable(None) is True
def test_bad_request_is_false(self):
assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False
class TestResponseField:
def test_rejected_sell_inactive_slot_has_retryable_false(self):
# slot is inactive (never bought) → slot_not_holding:inactive → terminal
repo, svc, intents, handler, slot = _wire()
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_holding:inactive"
assert resp.payload["retryable"] is False
def test_rejected_sell_unknown_slot_false(self):
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = _sell(handler, uuid4())
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_found"
assert resp.payload["retryable"] is False
def test_bad_request_sell_carries_retryable_false(self):
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": "x", # missing slot_id
}, correlation_id="x"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"].startswith("bad_request")
assert resp.payload["retryable"] is False
def test_admitted_sell_has_no_retryable(self):
repo, svc, intents, handler, slot = _wire()
# bring slot to holding so the sell is admitted
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "admitted"
assert "retryable" not in resp.payload
def test_buy_rejection_has_no_retryable(self):
# buy on a non-existent slot → rejected, but buy responses omit retryable
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()),
}, correlation_id="b1"))
assert resp.payload["status"] == "rejected"
assert "retryable" not in resp.payload