feat(intents): dcy-011 buy intent portfolio_id 필수 계약 + ledger skip 가시화 (BAT-015)

dcy-011 전 주기 delivery. issue #6(buy 식별자 정정) + issue #11(ledger silent skip) 흡수.
거버넌스: 기존 Implemented REQ는 change-req, 신규 기능만 신규 REQ(OQ-3).

- REQ-007 (change-req): buy admission portfolio_id 필수화 + SnapshotStore symbol
  resolve + unknown_portfolio 거부 (hard cutover, OQ-1/OQ-2)
- REQ-021 (change-req): buy 메시지 payload symbol 제거
- REQ-018 (change-req): _record_ledger skip 가시화 (WARN + ledger_skipped 카운터)
- REQ-055 (신규): closed↔ledger_outbox anti-join 운영 점검 find_ledger_gap_slots

코드: intents.py(BuyIntent portfolio_id 필수 + resolve_symbol 주입 + _admit_buy
reject 분기), messaging_handlers.py, runtime/daemon.py(_resolve_symbol 배선),
execution.py(skip WARN 로그), adapters/persistence/ops_checks.py(신규 anti-join).

검증: 단위 282 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 314 passed, ruff clean.
BAT-015(standard) release-candidate. srs/index Next ID → REQ-056.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:57:51 +09:00
co-authored by Claude Opus 4.8
parent a24ebb242e
commit aee3512383
27 changed files with 729 additions and 33 deletions
+25 -4
View File
@@ -45,15 +45,19 @@ class IntentDecision:
@dataclass(frozen=True)
class BuyIntent:
# dcy-011 / REQ-007: portfolio_id (= asset_id) is the required canonical key.
# symbol is no longer a client input — it is resolved server-side from the
# universe (SnapshotStore) at admission. It remains here as the resolved
# value carried onto the slot.
intent_id: str
trading_date: date
symbol: str
portfolio_id: str
symbol: Optional[str] = None
qty: Optional[float] = None
allocated_capital: Optional[float] = None
entry_price: Optional[float] = None
strategy_id: Optional[str] = None
sell_strategy_id: Optional[str] = None
portfolio_id: Optional[str] = None
forced_close_at: Optional[datetime] = None
stop_loss_price: Optional[Decimal] = None # REQ-035
take_profit_price: Optional[Decimal] = None # REQ-035
@@ -77,10 +81,15 @@ class IntentService:
slot_service: SlotService,
slot_repo: SlotRepository,
intent_store: IntentStore,
resolve_symbol=None,
) -> None:
self._svc = slot_service
self._repo = slot_repo
self._intents = intent_store
# dcy-011 / REQ-007: portfolio_id -> symbol resolver (e.g. SnapshotStore-backed).
# callable(portfolio_id) -> Optional[str]. When set, admission resolves symbol
# from the universe and rejects unknown_portfolio if absent.
self._resolve_symbol = resolve_symbol
# ------------------------------------------------------------
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
@@ -139,6 +148,18 @@ 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.
if not intent.portfolio_id:
return IntentDecision(intent.intent_id, "rejected", None, "portfolio_id_required")
symbol = intent.symbol
if self._resolve_symbol is not None:
symbol = self._resolve_symbol(intent.portfolio_id)
if not symbol:
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
elif not symbol:
# No resolver wired and no symbol carried — cannot determine the order target.
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
# Retry on the last-idle-slot race: another intent may claim the slot
# between our find and transit (SlotStateMismatchError).
for _ in range(_MAX_IDLE_RETRY):
@@ -152,7 +173,7 @@ class IntentService:
SlotState.PENDING_BUY,
reason="buy_intent",
actor="intent_api",
symbol=intent.symbol,
symbol=symbol,
qty=intent.qty,
allocated_capital=intent.allocated_capital,
entry_price=intent.entry_price,
@@ -162,7 +183,7 @@ class IntentService:
forced_close_at=intent.forced_close_at,
stop_loss_price=intent.stop_loss_price,
take_profit_price=intent.take_profit_price,
extra={"intent_id": intent.intent_id, "symbol": intent.symbol},
extra={"intent_id": intent.intent_id, "symbol": symbol},
)
except SlotStateMismatchError:
continue # slot was claimed concurrently; try another idle slot