From b80407098e9d19a89d5c4877640efd270a5f4915 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 10 Jun 2026 12:09:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(slot):=20BAT-029=20M1=20=E2=80=94=20intent?= =?UTF-8?q?=20=EA=B3=84=EC=95=BD=20=ED=82=A4=20slot=5Fid=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20(dcy-020/REQ-061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매수 계약 키를 portfolio_id → slot_id 로 이동(매도는 이미 slot_id). 외부 전략 무중단 위해 portfolio_id 레거시 dual-accept. - BuyIntent: slot_id 필드 추가, portfolio_id Optional 완화 - _admit_buy: 자산 중심 모드 우선 분기; _admit_buy_asset_centric 가 slot_id(canonical) 또는 portfolio_id(legacy dual-accept)로 슬롯 해소, symbol 은 슬롯 portfolio_id 로 resolve. unknown_slot/unknown_portfolio 사유 구분. - messaging_handlers: buy 파싱에 slot_id 수용 - 신규 테스트: slot_id 매수 / unknown_slot / 기존 dual-accept 회귀 검증: 전체 389 passed / 36 skipped, ruff clean. Co-Authored-By: Claude Opus 4.8 --- .../test_asset_centric_admission.py | 19 +++++++ trex_core/application/intents.py | 54 +++++++++++++------ trex_core/application/messaging_handlers.py | 9 ++-- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/tests/application/test_asset_centric_admission.py b/tests/application/test_asset_centric_admission.py index f0f5710..3c47c40 100644 --- a/tests/application/test_asset_centric_admission.py +++ b/tests/application/test_asset_centric_admission.py @@ -34,6 +34,25 @@ def test_activates_asset_slot(): assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930" +def test_activates_by_slot_id(): + # dcy-020/REQ-061: slot_id is the canonical buy contract key. + repo, svc, intents = _wire(max_active=2) + repo.reconcile_universe(INST, DAY, {"pf-a"}) + slot = repo.find_slot_by_portfolio(INST, "pf-a") + d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=slot.slot_id)) + assert d.status == "admitted" and d.slot_id == slot.slot_id + got = repo.fetch_slot(slot.slot_id) + assert got.status is SlotState.PENDING_BUY and got.symbol == "005930" # resolved from slot.portfolio_id + + +def test_unknown_slot_for_bad_slot_id(): + from uuid import uuid4 + repo, svc, intents = _wire(max_active=2) + repo.reconcile_universe(INST, DAY, {"pf-a"}) + d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=uuid4())) + assert d.status == "rejected" and d.reason == "unknown_slot" + + def test_already_active_is_rejected(): repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"}) diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 76b4765..40b83d7 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -51,7 +51,11 @@ class BuyIntent: # value carried onto the slot. intent_id: str trading_date: date - portfolio_id: str + # dcy-020 / REQ-061 (BAT-029): asset-centric contract key. slot_id is the + # canonical buy key (snapshot slots[] round-trip); portfolio_id is retained + # for the legacy pool path and as dual-accept in asset-centric mode. + portfolio_id: Optional[str] = None + slot_id: Optional[UUID] = None symbol: Optional[str] = None qty: Optional[float] = None allocated_capital: Optional[float] = None @@ -159,8 +163,13 @@ class IntentService: return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason) def _admit_buy(self, intent: BuyIntent) -> IntentDecision: - # dcy-011 / REQ-007: portfolio_id is required; symbol is resolved from the - # universe (no client symbol). FR-1/FR-2/FR-3. + # dcy-020 / REQ-061 (BAT-029): asset-centric mode keys buy by slot_id + # (legacy portfolio_id dual-accept); the target slot + symbol are resolved + # inside the asset-centric path. + if self._max_active_slots is not None: + return self._admit_buy_asset_centric(intent) + # legacy pool path (dcy-011 / REQ-007): portfolio_id required; symbol is + # resolved server-side from the universe. if not intent.portfolio_id: return IntentDecision(intent.intent_id, "rejected", None, "portfolio_id_required") symbol = intent.symbol @@ -171,9 +180,6 @@ 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") - # dcy-020 / REQ-059 (BAT-028): asset-centric admission when M is configured. - if self._max_active_slots is not None: - return self._admit_buy_asset_centric(intent, symbol) # 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 @@ -211,21 +217,39 @@ class IntentService: return IntentDecision(intent.intent_id, "rejected", None, "idle_slot_contended") # ------------------------------------------------------------ - def _admit_buy_asset_centric(self, intent: BuyIntent, symbol: str) -> IntentDecision: - """dcy-020 / REQ-059: activate the asset's permanent slot under the M cap. + def _admit_buy_asset_centric(self, intent: BuyIntent) -> IntentDecision: + """dcy-020 / REQ-059·REQ-061: activate the slot under the M cap. - FCFS + no eviction: if the asset is already active -> already_active; if the - instance is at M active slots -> capacity_full (existing holdings untouched, - incl. over-cap when M is lowered). Single intent thread + instance-scoped - advisory lock serialize admissions (REQ-002 NFR-1 / dcy-020 OQ-1).""" + Contract key is slot_id (REQ-061); portfolio_id is accepted as a legacy + dual-accept mapping. Symbol is resolved from the slot's portfolio_id. + FCFS + no eviction: already-active -> already_active; at M -> capacity_full + (existing holdings untouched, incl. over-cap). Single intent thread + + instance-scoped advisory lock serialize admissions (dcy-020 OQ-1).""" _ACTIVE = (SlotState.PENDING_BUY, SlotState.HOLDING, SlotState.PENDING_SELL) + + def _resolve(sl): + pid = sl.portfolio_id + if self._resolve_symbol is not None and pid: + return self._resolve_symbol(pid) + return intent.symbol or sl.symbol + for _ in range(_MAX_IDLE_RETRY): - slot = self._repo.find_slot_by_portfolio(self._instance_key, intent.portfolio_id) + # resolve the target slot: slot_id (canonical) or portfolio_id (legacy) + if intent.slot_id is not None: + slot = self._repo.fetch_slot(intent.slot_id) + miss_reason = "unknown_slot" + elif intent.portfolio_id: + slot = self._repo.find_slot_by_portfolio(self._instance_key, intent.portfolio_id) + miss_reason = "unknown_portfolio" + else: + return IntentDecision(intent.intent_id, "rejected", None, "slot_id_or_portfolio_required") if slot is None: - # no asset slot for this instance (not in reconciled universe) - return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") + return IntentDecision(intent.intent_id, "rejected", None, miss_reason) if slot.status in _ACTIVE: return IntentDecision(intent.intent_id, "rejected", None, "already_active") + symbol = _resolve(slot) + if not symbol: + return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") if self._repo.count_active(self._instance_key) >= self._max_active_slots: return IntentDecision(intent.intent_id, "rejected", None, "capacity_full") try: diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 77684e7..9ff2a13 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -72,13 +72,14 @@ def make_intent_rpc_handler( intent_type = str(body.get("intent_type") or "").lower() try: if intent_type == "buy": - # dcy-011 / REQ-021: buy payload carries portfolio_id (required); - # symbol is NOT a client field — admission resolves it from the - # universe. portfolio_id absence is rejected at admission (FR-1). + # dcy-011 / REQ-021: buy payload carries portfolio_id (legacy) or, + # in asset-centric mode (dcy-020/REQ-061), slot_id (canonical key). + # symbol is NOT a client field — resolved from the universe. buy = BuyIntent( intent_id=intent_id, trading_date=_parse_date(body.get("trading_date")), - portfolio_id=_opt_str(body.get("portfolio_id")) or "", + portfolio_id=_opt_str(body.get("portfolio_id")) or None, + slot_id=(UUID(str(body["slot_id"])) if body.get("slot_id") else None), qty=_opt_float(body.get("qty")), allocated_capital=_opt_float(body.get("allocated_capital")), entry_price=_opt_float(body.get("entry_price")),