Files
trex-core/trex_core/application/intents.py
T
genichinandClaude Opus 4.8 6e8e79660c feat(slot): slot entry_count + closed-slot reallocation (dcy-019, BAT-025) + REL-005
dcy-019/issue #18: slot 자원을 진입 횟수 기준으로 고르게 재사용하고 idle 소진 시
closed 슬롯을 직접 재할당한다. 거버넌스는 전부 change-req(신규 REQ 없음).

- 도메인(REQ-002): Slot.entry_count(기본 0) + EXPECTED_SLOT_FIELDS, 상태 머신
  closed→pending_buy 신규 전이(base 8→9, ALLOWED_TRANSITIONS 14→15),
  _apply_transition_fields closed→pending_buy clean-slate + →pending_buy 시 +1
- 영속화(REQ-006): 멱등 마이그레이션 008(entry_count), flat 매핑,
  find_allocatable_slot(idle∪closed, entry_count→updated_at→slot_id) + in-memory 동등
- admission(REQ-007): _admit_buy find_idle_slot→find_allocatable_slot + from_state=slot.status
- 테스트(REQ-003): 전이 카운트/계약 가드 정합 + test_slot_entry_count.py / test_buy_admission_reallocation.py
- 문서(REQ-009): data-model.md / runtime-flows.md 정합
- 기존 daily_symbol_entries(symbol-scoped) 분리·미변경

BAT-025(release-candidate) delivery + change-req REQ-002/003/006/007/009(CHG-20260609-01).
REL-005(confirmed, app-service): BAT-024+BAT-025 묶음. operator-facing run-the-app
스모크로 enter --forced-close-at(BAT-024)·closed 재할당(BAT-025) 실 데몬 검증
(실 PG 마이그레이션 008 round-trip 포함).

검증: 단위 366 passed / 35 skipped(KIS sandbox 1건 deselect — live .env 환경 의존), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:49:22 +09:00

225 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Intent application service (REQ-007).
Turns a buy/sell intent submitted over HTTP into a single-writer slot
transition, with intent_id idempotency backed by a durable intent_log.
Idempotency model (dcy-002 OQ-1): `intent_store.try_insert` is the dedup
gate. On a duplicate intent_id it returns the prior decision without
re-driving any transition (NFR-3). Slot transitions go exclusively through
SlotService (advisory-lock serialized) so the single-writer invariant
holds (NFR-2). Concurrency on the last idle slot is resolved by retrying
on SlotStateMismatchError.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
from trex_core.application.ports import IntentStore
from trex_core.domain.slot import (
SlotRepository,
SlotService,
SlotState,
SlotStateMismatchError,
)
_MAX_IDLE_RETRY = 8
@dataclass(frozen=True)
class IntentDecision:
"""Result of submitting an intent."""
intent_id: str
status: str # 'admitted' | 'rejected'
slot_id: Optional[UUID] = None
reason: Optional[str] = None
@property
def admitted(self) -> bool:
return self.status == "admitted"
@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
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
forced_close_at: Optional[datetime] = None
stop_loss_price: Optional[Decimal] = None # REQ-035
take_profit_price: Optional[Decimal] = None # REQ-035
extra: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SellIntent:
intent_id: str
slot_id: UUID
reason: str = "discretionary_sell"
sell_strategy_id: Optional[str] = None
# dcy-012 / REQ-007: optional sell limit price. None => market sell
# (the path used by safety-sell / kill-switch). Set => limit sell at price.
limit_price: Optional[float] = None
extra: dict[str, Any] = field(default_factory=dict)
class IntentService:
"""Admission + slot transition for buy/sell intents (single writer)."""
def __init__(
self,
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]:
"""Return the recorded decision for an intent_id, or None (re-query)."""
rec = self._intents.get(intent_id)
if rec is None:
return None
return IntentDecision(rec.intent_id, rec.status, rec.slot_id, rec.reason)
def submit_buy(self, intent: BuyIntent) -> IntentDecision:
payload = {
"trading_date": intent.trading_date.isoformat(),
"symbol": intent.symbol,
"qty": intent.qty,
"allocated_capital": intent.allocated_capital,
"entry_price": intent.entry_price,
"strategy_id": intent.strategy_id,
"sell_strategy_id": intent.sell_strategy_id,
"portfolio_id": intent.portfolio_id,
}
prior = self._dedup(intent.intent_id, "buy", payload)
if prior is not None:
return prior
decision = self._admit_buy(intent)
self._intents.finalize(
intent.intent_id, decision.status, decision.slot_id, decision.reason
)
return decision
def submit_sell(self, intent: SellIntent) -> IntentDecision:
payload = {
"slot_id": str(intent.slot_id),
"reason": intent.reason,
"sell_strategy_id": intent.sell_strategy_id,
}
prior = self._dedup(intent.intent_id, "sell", payload)
if prior is not None:
return prior
decision = self._admit_sell(intent)
self._intents.finalize(
intent.intent_id, decision.status, decision.slot_id, decision.reason
)
return decision
# ------------------------------------------------------------
def _dedup(self, intent_id: str, intent_type: str, payload: dict) -> Optional[IntentDecision]:
"""Return prior decision if intent_id already seen; else None (and reserve it)."""
inserted = self._intents.try_insert(intent_id, intent_type, payload)
if inserted:
return None
rec = self._intents.get(intent_id)
if rec is None: # extremely unlikely race; treat as not-yet-decided
return IntentDecision(intent_id, "rejected", None, "duplicate_in_flight")
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-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
# reallocation), ordered by entry_count asc → updated_at asc → slot_id.
# The candidate may already be CLOSED, so the transit's from_state must
# follow the slot's current status rather than a hardcoded IDLE.
for _ in range(_MAX_IDLE_RETRY):
slot = self._repo.find_allocatable_slot(intent.trading_date)
if slot is None:
return IntentDecision(intent.intent_id, "rejected", None, "no_idle_slot")
try:
updated = self._svc.transit_slot(
slot.slot_id,
slot.status,
SlotState.PENDING_BUY,
reason="buy_intent",
actor="intent_api",
symbol=symbol,
qty=intent.qty,
allocated_capital=intent.allocated_capital,
# dcy-012 / REQ-007: buy entry price maps to the slot's
# side-agnostic pending limit_price (cleared on fill→HOLDING).
limit_price=intent.entry_price,
strategy_id=intent.strategy_id,
sell_strategy_id=intent.sell_strategy_id,
portfolio_id=intent.portfolio_id,
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": symbol},
)
except SlotStateMismatchError:
continue # slot was claimed concurrently; try another idle slot
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
return IntentDecision(intent.intent_id, "rejected", None, "idle_slot_contended")
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
slot = self._repo.fetch_slot(intent.slot_id)
if slot is None:
return IntentDecision(intent.intent_id, "rejected", None, "slot_not_found")
if slot.status is not SlotState.HOLDING:
return IntentDecision(
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
)
try:
updated = self._svc.transit_slot(
slot.slot_id,
SlotState.HOLDING,
SlotState.PENDING_SELL,
reason=intent.reason,
actor="intent_api",
sell_strategy_id=intent.sell_strategy_id,
# dcy-012 / REQ-007: optional sell limit (None => market sell).
limit_price=intent.limit_price,
extra={"intent_id": intent.intent_id},
)
except SlotStateMismatchError:
return IntentDecision(intent.intent_id, "rejected", None, "slot_state_changed")
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)