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
+2 -4
View File
@@ -4,12 +4,12 @@ Storage-agnostic pure domain: no broker, DB driver, CLI, or config dependency.
A repository implementation is injected via the `SlotRepository` protocol.
Public API:
- SlotState: enum of 6 slot states + allowed transitions
- SlotState: enum of 5 slot states + allowed transitions
- Slot: data class for a slot row
- SlotCapacity: capacity snapshot returned by compute_slot_capacity
- SlotTransitionRecord: append-only transition record
- EXPECTED_SLOT_FIELDS: canonical `Slot` column contract (guarded by test_contract)
- SlotService: main domain service (preallocate, transit, capacity, entry count)
- SlotService: main domain service (transit, sell, thresholds, capacity)
- SlotRepository: abstract storage protocol
- SlotTransitionError / SlotStateMismatchError / SlotNotFoundError: domain errors
"""
@@ -34,7 +34,6 @@ from trex_core.domain.slot.state import (
SLOT_ADVISORY_LOCK_ID,
SlotState,
is_allowed_transition,
normalize_state,
slot_advisory_lock_id,
)
@@ -53,6 +52,5 @@ __all__ = [
"SlotTransitionError",
"SlotTransitionRecord",
"is_allowed_transition",
"normalize_state",
"slot_advisory_lock_id",
]
+28 -69
View File
@@ -1,4 +1,4 @@
"""In-memory SlotRepository — for unit tests and current trex-core runtime.
"""In-memory SlotRepository — for unit tests.
Simulates advisory lock via threading.Lock so contention tests are still
meaningful even without a real PostgreSQL instance.
@@ -8,7 +8,7 @@ from __future__ import annotations
import threading
from contextlib import contextmanager
from dataclasses import replace
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from typing import Iterator, Optional
from uuid import UUID, uuid4
@@ -23,7 +23,6 @@ class InMemorySlotRepository:
def __init__(self) -> None:
self._slots: dict[UUID, Slot] = {}
self._transitions: list[SlotTransitionRecord] = []
self._entries: dict[tuple[date, str], tuple[int, datetime]] = {}
# Per-lock-id threading.Lock; mirrors pg_advisory_xact_lock semantics
# at process level. lock_time and contention can be observed.
self._lock_table: dict[int, threading.Lock] = {}
@@ -48,53 +47,16 @@ class InMemorySlotRepository:
return lock
# --- daily_slot CRUD ---
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
existing = [s for s in self._slots.values() if s.trading_date == trading_date]
if existing:
return 0
now = datetime.now(tz=timezone.utc)
for _ in range(count):
slot_id = uuid4()
self._slots[slot_id] = Slot(
slot_id=slot_id,
trading_date=trading_date,
status=SlotState.IDLE,
created_at=now,
updated_at=now,
)
return count
def add_slot(self, slot: Slot) -> None:
"""Test seeding helper — insert a slot row directly."""
self._slots[slot.slot_id] = slot
def list_slots(self, trading_date: date) -> list[Slot]:
return [s for s in self._slots.values() if s.trading_date == trading_date]
def list_slots(self, instance_key: str) -> list[Slot]:
return [s for s in self._slots.values() if s.instance_key == instance_key]
def fetch_slot(self, slot_id: UUID) -> Optional[Slot]:
return self._slots.get(slot_id)
def find_idle_slot(self, trading_date: date) -> Optional[Slot]:
for s in self._slots.values():
if s.trading_date == trading_date and s.status is SlotState.IDLE:
return s
return None
def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]:
# dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc
# → updated_at asc → slot_id (deterministic, OQ-2).
candidates = [
s for s in self._slots.values()
if s.trading_date == trading_date
and s.status in (SlotState.IDLE, SlotState.CLOSED)
]
if not candidates:
return None
return min(
candidates,
key=lambda s: (
s.entry_count,
s.updated_at or datetime.min.replace(tzinfo=timezone.utc),
str(s.slot_id),
),
)
def update_slot(self, slot: Slot) -> None:
if slot.slot_id not in self._slots:
raise SlotNotFoundError(f"slot not found: {slot.slot_id}")
@@ -122,7 +84,7 @@ class InMemorySlotRepository:
]
def reconcile_universe(
self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str],
self, instance_key: str, universe_portfolio_ids: set[str],
*, allow_retire: bool = True,
) -> dict[str, int]:
existing = {
@@ -136,7 +98,7 @@ class InMemorySlotRepository:
if pid not in existing:
sid = uuid4()
self._slots[sid] = Slot(
slot_id=sid, trading_date=trading_date, status=SlotState.IDLE,
slot_id=sid, status=SlotState.INACTIVE,
instance_key=instance_key, portfolio_id=pid,
created_at=now, updated_at=now,
)
@@ -165,27 +127,23 @@ class InMemorySlotRepository:
def all_transition_log(self) -> list[SlotTransitionRecord]:
return list(self._transitions)
# --- daily_symbol_entries ---
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
key = (trading_date, symbol)
count, _ = self._entries.get(key, (0, datetime.min))
new_count = count + 1
self._entries[key] = (new_count, datetime.now(tz=timezone.utc))
return new_count
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
count, _ = self._entries.get((trading_date, symbol), (0, datetime.min))
return count
# --- order execution queue (BAT-004 / REQ-013, option A) ---
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
def find_pending_for_dispatch(self) -> Optional[Slot]:
"""A pending slot not yet dispatched (broker_order_id IS NULL)."""
for s in self._slots.values():
if s.status in self._PENDING and s.broker_order_id is None:
return s
return None
def list_undispatched_pending(
self, instance_key: str = "default", limit: int = 64,
) -> list[Slot]:
"""Pending slots not yet dispatched (broker_order_id IS NULL).
dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a DB
never dispatch each other's orders. List-based (vs single-row claim) so
one skipped sell (broker flat) cannot head-block the dispatch queue."""
out = [
s for s in self._slots.values()
if (s.instance_key == instance_key and s.status in self._PENDING
and s.broker_order_id is None)
]
return out[:limit]
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
"""Atomically set broker_order_id iff still undispatched (idempotency guard)."""
@@ -197,11 +155,12 @@ class InMemorySlotRepository:
)
return True
def list_dispatched_pending(self) -> list[Slot]:
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [
s for s in self._slots.values()
if s.status in self._PENDING and s.broker_order_id is not None
if (s.instance_key == instance_key and s.status in self._PENDING
and s.broker_order_id is not None)
]
def clear_dispatch(self, slot_id: UUID) -> None:
@@ -213,10 +172,10 @@ class InMemorySlotRepository:
)
# --- bulk counts ---
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
def count_slots_by_status(self, instance_key: str) -> dict[SlotState, int]:
result: dict[SlotState, int] = {}
for s in self._slots.values():
if s.trading_date != trading_date:
if s.instance_key != instance_key:
continue
result[s.status] = result.get(s.status, 0) + 1
return result
+24 -23
View File
@@ -1,8 +1,8 @@
"""Slot domain data classes (REQ-001, REQ-002)."""
"""Slot domain data classes (REQ-001, REQ-002; dcy-020/REQ-056 asset-centric)."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import datetime
from decimal import Decimal
from typing import Optional
from uuid import UUID
@@ -14,43 +14,37 @@ from trex_core.domain.slot.state import SlotState
class Slot:
"""Single daily_slot row.
Matches the slot column contract recorded in dcy-001 부록 A and
docs/data-model.md (trex-core stores this in-memory for now; a future
persistence layer must satisfy the same contract).
dcy-020/REQ-056 (BAT-030): the slot is a **permanent asset slot** — one row
per (instance_key, portfolio_id) universe asset, living across trading days.
Pool-model columns `trading_date` and `entry_count` were dropped at the
cutover (migration 011); time attributes live on events/ledger rows, not on
the slot. Matches docs/data-model.md.
"""
slot_id: UUID
trading_date: date
status: SlotState
symbol: Optional[str] = None
qty: Optional[float] = None
avg_price: Optional[float] = None
# dcy-012 / REQ-002: side-agnostic "current pending order limit price"
# (renamed from entry_price). Set on pending entry (buy/sell limit), cleared
# on pending exit (→HOLDING, →CLOSED) so a buy limit never leaks into a sell
# order (issue #10). None => market order at dispatch.
# on pending exit (→HOLDING, →INACTIVE) so a buy limit never leaks into a
# sell order (issue #10). None => market order at dispatch.
limit_price: Optional[float] = None
allocated_capital: Optional[float] = None
order_id: Optional[str] = None
strategy_id: Optional[str] = None
broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage)
portfolio_id: Optional[str] = None # BAT-006 / REQ-025 (trex-orbit lineage)
portfolio_id: Optional[str] = None # BAT-006 / REQ-025; asset binding (REQ-056)
sell_strategy_id: Optional[str] = None # BAT-008 / REQ-047 (trex-orbit lineage)
forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage)
stop_loss_price: Optional[Decimal] = None # REQ-035 / BAT-010
take_profit_price: Optional[Decimal] = None # REQ-035 / BAT-010
# dcy-019 / REQ-002 (BAT-025): slot-scoped cumulative entry count. +1 on each
# allocation transition (idle/closed → pending_buy); drives admission slot
# selection (lowest entry_count first). Distinct from the symbol-scoped
# daily_symbol_entries counter (REQ-002 AC-3) — different key/purpose.
entry_count: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# dcy-020 / REQ-056 (BAT-027): asset-centric permanent slot foundation (안 B).
# instance_key scopes slots per daemon instance in a shared DB
# (UNIQUE(instance_key, portfolio_id)); advisory lock is also instance-scoped.
# retired_at marks a soft-retired / universe-detached slot (OQ-2). Both added
# additively here; entry_count/trading_date removal is deferred to cutover (BAT-δ).
# dcy-020 / REQ-056: instance_key scopes slots per daemon instance in a
# shared DB (UNIQUE(instance_key, portfolio_id)); advisory lock is also
# instance-scoped. retired_at marks a soft-retired / universe-detached slot.
instance_key: str = "default"
retired_at: Optional[datetime] = None
@@ -61,7 +55,6 @@ class Slot:
# / docs/data-model.md), not here — names/presence only.
EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"slot_id",
"trading_date",
"status",
"symbol",
"qty",
@@ -76,7 +69,6 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"forced_close_at",
"stop_loss_price", # REQ-035
"take_profit_price", # REQ-035
"entry_count", # dcy-019 / REQ-002 (BAT-025)
"created_at",
"updated_at",
"instance_key", # dcy-020 / REQ-056 (BAT-027)
@@ -86,11 +78,20 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
@dataclass(frozen=True)
class SlotCapacity:
"""Snapshot of slot capacity returned by compute_slot_capacity (REQ-002 AC-6)."""
"""Capacity snapshot returned by compute_slot_capacity (REQ-002 AC-6;
dcy-020/REQ-059 asset-centric redefinition, BAT-030).
idle_count: int
active_count = holding + pending (position-occupying). available_count is
the remaining admission headroom: max(0, M − active) under a max_active_slots
cap M, else the inactive (admittable) slot count. total_slots = M when set,
else the instance's slot count. scarcity_ratio = 1 − available/total.
"""
inactive_count: int
holding_count: int
pending_count: int
active_count: int
available_count: int
total_slots: int
scarcity_ratio: float
+15 -42
View File
@@ -1,13 +1,12 @@
"""Abstract slot repository protocol (REQ-002).
"""Abstract slot repository protocol (REQ-002; dcy-020 asset-centric).
Concrete implementations:
- InMemorySlotRepository — unit tests / current trex-core runtime
- (future) PostgreSQL-backed repository — added in the persistence iteration
- InMemorySlotRepository — unit tests
- PostgreSQLSlotRepository — production persistence
"""
from __future__ import annotations
from contextlib import AbstractContextManager
from datetime import date
from typing import Optional, Protocol
from uuid import UUID
@@ -22,6 +21,11 @@ class SlotRepository(Protocol):
- `transactional_advisory_lock()` runs the with-block inside a single transaction
protected by an advisory lock. External I/O MUST NOT be performed inside the block.
- `append_transition_log()` is append-only (no UPDATE/DELETE).
dcy-020 (BAT-030): slots are permanent asset slots keyed by
(instance_key, portfolio_id); all listing/counting is instance-scoped.
The pool-model surface (pre_allocate_slots / find_idle_slot /
find_allocatable_slot / daily_symbol_entries) was removed at the cutover.
"""
# --- lock + transaction ---
@@ -33,32 +37,14 @@ class SlotRepository(Protocol):
...
# --- daily_slot CRUD ---
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
"""Insert `count` idle slots for trading_date if none exist; return inserted count.
Idempotent — if slots for trading_date already exist, returns 0 (REQ-002 AC-4).
"""
...
def list_slots(self, trading_date: date) -> list[Slot]:
"""Return all slots for the trading day."""
def list_slots(self, instance_key: str) -> list[Slot]:
"""Return all slots owned by this instance."""
...
def fetch_slot(self, slot_id: UUID) -> Optional[Slot]:
"""Return single slot row or None."""
...
def find_idle_slot(self, trading_date: date) -> Optional[Slot]:
"""Return any one idle slot (None if exhausted)."""
...
def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]:
"""Return the best buy-admission candidate among idle ∪ closed slots
(dcy-019 / REQ-006), ordered by `entry_count` asc → `updated_at` asc →
`slot_id`. None if neither idle nor closed slots remain. Concurrency on
the last candidate is handled by the caller (advisory lock + retry)."""
...
def update_slot(self, slot: Slot) -> None:
"""Persist slot row (UPDATE by slot_id)."""
...
@@ -66,8 +52,7 @@ class SlotRepository(Protocol):
# --- asset-centric (dcy-020 / BAT-028) ---
def find_slot_by_portfolio(self, instance_key: str, portfolio_id: str) -> Optional[Slot]:
"""Return the (single, permanent) slot bound to `portfolio_id` for this
instance (dcy-020/REQ-059), or None. Keyed by (instance_key, portfolio_id)
— independent of trading_date."""
instance (dcy-020/REQ-059), or None."""
...
def count_active(self, instance_key: str) -> int:
@@ -77,11 +62,11 @@ class SlotRepository(Protocol):
def list_holding_slots(self, instance_key: str) -> list[Slot]:
"""Return holding slots for this instance (dcy-020/REQ-062 divergence
reconcile)."""
reconcile; safety-sell / kill-switch sweeps)."""
...
def reconcile_universe(
self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str],
self, instance_key: str, universe_portfolio_ids: set[str],
*, allow_retire: bool = True,
) -> dict[str, int]:
"""Reconcile the slot set to the portfolio-hub universe (dcy-020/REQ-058).
@@ -101,19 +86,7 @@ class SlotRepository(Protocol):
"""Append one row to slot_transition_log (no UPDATE/DELETE allowed)."""
...
# --- daily_symbol_entries ---
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
"""Upsert (trading_date, symbol) with entry_count += 1, last_entry_at = now.
Returns the resulting entry_count (REQ-002 AC-3).
"""
...
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
"""Return today's entry_count for symbol, or 0 if no row."""
...
# --- bulk counts for capacity ---
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
"""Return dict mapping SlotState -> count for trading_date."""
def count_slots_by_status(self, instance_key: str) -> dict[SlotState, int]:
"""Return dict mapping SlotState -> count for this instance."""
...
+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,
)
+13 -35
View File
@@ -1,12 +1,10 @@
"""Slot state machine (REQ-001, REQ-002; dcy-020/REQ-057 asset-centric redefinition).
"""Slot state machine (REQ-001, REQ-002; dcy-020/REQ-057 asset-centric).
dcy-020 (안 B): the asset-centric permanent slot collapses the pool-model `idle`
(never-bought) and `closed` (sold, now flat) into a single `inactive` (no open
position) state, and removes the `closed -> idle` recovery transition. `idle` and
`closed` are retained as **backward-compatible aliases** that normalize to
`inactive` for transition checks, so existing rows/runtime stay valid until the
cutover (BAT-δ) drops the aliases. The canonical transition set is expressed in
terms of `inactive`.
The asset-centric permanent slot has five states. `inactive` means "no open
position" — it replaced the pool-model `idle` (never-bought) and `closed`
(sold, now flat) states, whose aliases were removed at the cutover (BAT-030);
migration 010 rewrites any remaining `idle`/`closed` rows to `inactive` and
swaps the status CHECK constraint accordingly.
"""
from __future__ import annotations
@@ -30,34 +28,18 @@ def slot_advisory_lock_id(instance_key: str) -> int:
class SlotState(str, Enum):
"""Slot lifecycle states. `inactive` is the asset-centric standard
(dcy-020/REQ-057); `idle`/`closed` are retained as aliases normalizing to
`inactive` (removed at cutover, BAT-δ)."""
"""Slot lifecycle states (dcy-020/REQ-057 asset-centric, 5 states)."""
INACTIVE = "inactive" # dcy-020: no open position (replaces idle/closed)
IDLE = "idle" # alias -> inactive (pool-model lineage; drop at BAT-δ)
INACTIVE = "inactive" # no open position (permanent slot at rest)
PENDING_BUY = "pending_buy"
HOLDING = "holding"
PENDING_SELL = "pending_sell"
CLOSED = "closed" # alias -> inactive (pool-model lineage; drop at BAT-δ)
ERROR = "error"
# idle/closed are pool-model aliases of the asset-centric `inactive` state.
_INACTIVE_ALIASES: frozenset[SlotState] = frozenset(
{SlotState.IDLE, SlotState.CLOSED, SlotState.INACTIVE}
)
def normalize_state(state: SlotState) -> SlotState:
"""Map pool-model aliases (idle/closed) to the canonical `inactive`."""
return SlotState.INACTIVE if state in _INACTIVE_ALIASES else state
# Canonical base transitions in terms of `inactive` (dcy-020/REQ-057).
# `closed -> idle` recovery is REMOVED (it normalizes to inactive->inactive, a
# disallowed self-loop). `inactive -> pending_buy` covers both the old
# idle->pending_buy and closed->pending_buy (reallocation) families.
# Canonical transitions (dcy-020/REQ-057). There is no inactive self-loop and
# no closed->idle recovery family — a permanent slot simply returns to
# `inactive` and may activate again via inactive->pending_buy.
_BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
(SlotState.INACTIVE, SlotState.PENDING_BUY), # 1. buy decision (activate)
@@ -85,15 +67,11 @@ _FROM_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
)
# Canonical allowed transitions (inactive-based). Use `is_allowed_transition`
# which normalizes aliases before lookup.
ALLOWED_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = (
_BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS
)
def is_allowed_transition(from_state: SlotState, to_state: SlotState) -> bool:
"""Return True iff (from_state, to_state) — after normalizing idle/closed to
inactive — is an allowed transition family. `closed->idle` and any
inactive self-loop are rejected (dcy-020/REQ-057)."""
return (normalize_state(from_state), normalize_state(to_state)) in ALLOWED_TRANSITIONS
"""Return True iff (from_state, to_state) is an allowed transition."""
return (from_state, to_state) in ALLOWED_TRANSITIONS