feat(slot): BAT-029 M1 — intent 계약 키 slot_id 통일 (dcy-020/REQ-061)
매수 계약 키를 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user