feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)

M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거),
마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성.
M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty,
미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지).
M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP),
repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드),
admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active),
daily_symbol_entries 코드 경로 제거.
M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/
--allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건.
M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract
자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합.

검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트
가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1):
reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→
매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green.
실 cutover 는 운영자 실행(REL 단계, D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:16:15 +09:00
co-authored by Claude Opus 4.8
parent cec34be5f4
commit 7d727824dc
67 changed files with 1584 additions and 1862 deletions
@@ -6,9 +6,6 @@ trigger exactly ONE slot transition (REQ-023 AC-1).
"""
from __future__ import annotations
from datetime import date
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
@@ -16,16 +13,16 @@ from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 5)
def _wire():
def _wire(max_active=3):
repo = InMemorySlotRepository()
svc = SlotService(repo)
# dcy-011 / REQ-007: buy resolves symbol from the universe via resolver.
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
svc.preallocate_daily_slots(DAY, 3)
resolve_symbol=lambda pid: "005930",
max_active_slots=max_active)
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3", "PF-9"})
return repo, svc, intents
@@ -35,7 +32,6 @@ def _buy_req(intent_id: str, portfolio_id: str = "PF-1") -> RpcRequest:
payload={
"intent_type": "buy",
"intent_id": intent_id,
"trading_date": DAY.isoformat(),
"portfolio_id": portfolio_id,
"qty": 10,
},
@@ -61,18 +57,18 @@ def test_duplicate_correlation_id_dedups_via_intent_log():
r1 = handler(_buy_req("buy-dup"))
r2 = handler(_buy_req("buy-dup"))
assert r1.payload == r2.payload
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1
def test_buy_rejected_when_no_idle_slot():
repo, _, intents = _wire()
def test_buy_rejected_when_capacity_full():
repo, _, intents = _wire(max_active=3)
handler = make_intent_rpc_handler(intents)
for i in range(3):
assert handler(_buy_req(f"buy-{i}")).payload["status"] == "admitted"
resp = handler(_buy_req("overflow"))
for i, pid in enumerate(("PF-1", "PF-2", "PF-3")):
assert handler(_buy_req(f"buy-{i}", portfolio_id=pid)).payload["status"] == "admitted"
resp = handler(_buy_req("overflow", portfolio_id="PF-9"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "no_idle_slot"
assert resp.payload["reason"] == "capacity_full"
def test_sell_admission_from_holding():
@@ -121,7 +117,6 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
req = RpcRequest(
payload={
"intent_type": "buy",
"trading_date": DAY.isoformat(),
"portfolio_id": "PF-1",
"qty": 5,
},
@@ -131,33 +126,28 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
r2 = handler(req)
assert r1.payload["status"] == "admitted"
assert r1.payload == r2.payload
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1
# ---- dcy-011 / REQ-007/021: portfolio_id required + symbol resolve ----------
def test_buy_without_portfolio_id_rejected():
"""FR-1 / S-1: missing portfolio_id → portfolio_id_required reject."""
def test_buy_without_key_rejected():
"""dcy-020/REQ-061: missing slot_id AND portfolio_id → reject."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
req = RpcRequest(
payload={"intent_type": "buy", "intent_id": "no-pf",
"trading_date": DAY.isoformat(), "qty": 5},
payload={"intent_type": "buy", "intent_id": "no-pf", "qty": 5},
correlation_id="no-pf",
)
resp = handler(req)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "portfolio_id_required"
assert resp.payload["reason"] == "slot_id_or_portfolio_required"
def test_buy_unknown_portfolio_rejected():
"""FR-3 / S-1: portfolio_id absent from universe → unknown_portfolio reject."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: None) # universe empty
svc.preallocate_daily_slots(DAY, 3)
"""FR-3 / S-1: portfolio_id with no asset slot → unknown_portfolio reject."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req("u1", portfolio_id="PF-X"))
assert resp.payload["status"] == "rejected"