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
+44 -85
View File
@@ -1,17 +1,19 @@
"""Slot domain service — 4 main APIs (REQ-002).
"""Slot domain service (REQ-002; dcy-020 asset-centric).
- preallocate_daily_slots(trading_date, count)
- transit_slot(slot_id, from_state, to_state, reason, actor, **fields)
- get_today_entry_count(trading_date, symbol)
- compute_slot_capacity(trading_date, total_slots)
- sell(slot_id, actor, reason, limit_price=None)
- update_thresholds(slot_id, ...)
- compute_slot_capacity(max_active_slots=None)
All state-mutating APIs run inside repository.transactional_advisory_lock()
to serialize Buy/Sell decision threads + operator CLI (REQ-002 AC-2).
dcy-020 (BAT-030): the pool-model surface (daily preallocation, symbol entry
counters) was removed at the cutover; slots are permanent asset slots.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
@@ -44,21 +46,9 @@ class SlotService:
self._instance_key = instance_key
self._lock_id = slot_advisory_lock_id(instance_key)
# ------------------------------------------------------------
# preallocate_daily_slots
# ------------------------------------------------------------
def preallocate_daily_slots(self, trading_date: date, count: int) -> int:
"""Insert `count` idle slots for trading_date if none exist.
Idempotent: second+ call on the same trading_date returns 0.
Returns number of slots inserted.
REQ-002 AC-4, REQ-003 AC-5 (count must be > 0).
"""
if count <= 0:
raise ValueError(f"slot count must be > 0, got {count}")
with self._repo.transactional_advisory_lock(self._lock_id):
return self._repo.pre_allocate_slots(trading_date, count)
@property
def instance_key(self) -> str:
return self._instance_key
# ------------------------------------------------------------
# transit_slot
@@ -94,7 +84,6 @@ class SlotService:
3. verify (from_state, to_state) is in the allowed set
4. update slot row (fields per to_state semantics)
5. append transition_log row
6. on closed->idle, increment daily_symbol_entries (REQ-002 AC-3)
External I/O (broker, kis-data-hub, portfolio-hub) MUST NOT be performed
inside this function — caller does I/O outside the lock (REQ-002 AC-5).
@@ -135,14 +124,6 @@ class SlotService:
)
self._repo.update_slot(updated)
# closed -> idle recovery: bump daily_symbol_entries atomically
# (REQ-002 AC-3, REQ-001 split-by-purpose).
if from_state is SlotState.CLOSED and to_state is SlotState.IDLE:
# Use the symbol carried by the slot before reset.
symbol_for_entry = slot.symbol
if symbol_for_entry:
self._repo.increment_symbol_entry(slot.trading_date, symbol_for_entry)
self._repo.append_transition_log(
SlotTransitionRecord(
slot_id=slot_id,
@@ -177,23 +158,19 @@ class SlotService:
) -> Slot:
"""Compute the post-transition Slot row.
Special: closed -> idle resets occupancy fields (FR-2 #7).
Special: pending_buy -> idle resets occupancy (REQ-011 AC-6 rollback).
dcy-020/REQ-057 (BAT-030): entering INACTIVE deactivates the permanent
asset slot — all position/occupancy fields are cleared while the asset
identity (instance_key, portfolio_id, symbol, retired_at) is preserved.
Covers buy rollback (pending_buy→inactive), sell fill
(pending_sell→inactive) and operator error resolve (error→inactive).
dcy-012 / REQ-002: limit_price is set only while a slot is in a pending
state (the live order's limit); entering any non-pending state
(IDLE/HOLDING/CLOSED) clears it so a buy limit never leaks into the next
sell order (issue #10 root cause).
state (the live order's limit); entering any non-pending state clears it
so a buy limit never leaks into the next sell order (issue #10).
"""
if to_state is SlotState.IDLE and slot.status in {
SlotState.CLOSED,
SlotState.PENDING_BUY,
}:
# Recovery / rollback: clear occupancy + broker handle.
# transition_log retains audit trail.
if to_state is SlotState.INACTIVE:
return replace(
slot,
status=SlotState.IDLE,
symbol=None,
status=SlotState.INACTIVE,
qty=None,
avg_price=None,
limit_price=None,
@@ -201,7 +178,6 @@ class SlotService:
order_id=None,
strategy_id=None,
broker_order_id=None,
portfolio_id=None,
sell_strategy_id=None,
forced_close_at=None,
stop_loss_price=None,
@@ -209,30 +185,13 @@ class SlotService:
updated_at=datetime.now(tz=timezone.utc),
)
# dcy-019 / REQ-002 (BAT-025): closed-slot direct reallocation. A closed
# slot still carries the prior cycle's occupancy; start from a clean
# slate so a previous position/broker handle never leaks into the new
# entry. entry_count is intentionally preserved (slot-scoped cumulative).
if to_state is SlotState.PENDING_BUY and slot.status is SlotState.CLOSED:
slot = replace(
slot,
symbol=None, qty=None, avg_price=None, limit_price=None,
allocated_capital=None, order_id=None, strategy_id=None,
broker_order_id=None, portfolio_id=None, sell_strategy_id=None,
forced_close_at=None, stop_loss_price=None, take_profit_price=None,
)
# limit_price lifecycle (dcy-012 FR-1): set only on entering a pending
# state (the order's limit); cleared on entering HOLDING/CLOSED.
# state (the order's limit); cleared on entering HOLDING/ERROR.
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}:
new_limit_price = limit_price if limit_price is not None else slot.limit_price
else: # HOLDING / CLOSED — pending order limit no longer applies.
else: # HOLDING / ERROR — pending order limit no longer applies.
new_limit_price = None
# dcy-019 / REQ-002: +1 on each allocation transition (→pending_buy),
# for both idle→pending_buy and closed→pending_buy (FR-4).
new_entry_count = slot.entry_count + (1 if to_state is SlotState.PENDING_BUY else 0)
# Generic update: caller-supplied fields override; None means "keep".
return replace(
slot,
@@ -266,7 +225,6 @@ class SlotService:
take_profit_price=(
take_profit_price if take_profit_price is not None else slot.take_profit_price
),
entry_count=new_entry_count, # dcy-019 / REQ-002
updated_at=datetime.now(tz=timezone.utc),
)
@@ -353,37 +311,38 @@ class SlotService:
self._repo.update_slot(updated)
return "updated"
# ------------------------------------------------------------
# get_today_entry_count
# ------------------------------------------------------------
def get_today_entry_count(self, trading_date: date, symbol: str) -> int:
"""Return today's entry_count for a symbol (0 if no entries yet).
Used by buy decision state gate to enforce re-entry policy.
Reads daily_symbol_entries (decision input), NEVER queries
slot_transition_log (audit only). REQ-001, dcy-001 5.3.5.5.
"""
return self._repo.get_symbol_entry_count(trading_date, symbol)
# ------------------------------------------------------------
# compute_slot_capacity
# ------------------------------------------------------------
def compute_slot_capacity(self, trading_date: date, total_slots: int) -> SlotCapacity:
"""Return idle/holding/pending counts + scarcity_ratio (REQ-002 AC-6).
def compute_slot_capacity(self, max_active_slots: Optional[int] = None) -> SlotCapacity:
"""Return instance-scoped capacity counts (REQ-002 AC-6; dcy-020/REQ-059).
scarcity_ratio = 1 - idle_count / total_slots, clamped to [0.0, 1.0].
active = holding + pending (position-occupying). Under a
`max_active_slots` cap M, available = max(0, M − active) and
total_slots = M; without a cap, available = inactive slot count and
total_slots = the instance's slot count.
scarcity_ratio = 1 − available/total, clamped to [0.0, 1.0].
"""
if total_slots <= 0:
raise ValueError(f"total_slots must be > 0, got {total_slots}")
counts = self._repo.count_slots_by_status(trading_date)
idle = counts.get(SlotState.IDLE, 0)
counts = self._repo.count_slots_by_status(self._instance_key)
inactive = counts.get(SlotState.INACTIVE, 0)
holding = counts.get(SlotState.HOLDING, 0)
pending = counts.get(SlotState.PENDING_BUY, 0) + counts.get(SlotState.PENDING_SELL, 0)
scarcity = max(0.0, min(1.0, 1.0 - (idle / total_slots)))
active = holding + pending
if max_active_slots is not None and max_active_slots > 0:
total = max_active_slots
available = max(0, max_active_slots - active)
else:
total = sum(counts.values())
available = inactive
scarcity = (
max(0.0, min(1.0, 1.0 - (available / total))) if total > 0 else 0.0
)
return SlotCapacity(
idle_count=idle,
inactive_count=inactive,
holding_count=holding,
pending_count=pending,
total_slots=total_slots,
active_count=active,
available_count=available,
total_slots=total,
scarcity_ratio=scarcity,
)