Files
trex-core/tests/domain/slot/test_service.py
T
genichinandClaude Opus 4.8 7d727824dc 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>
2026-06-10 15:16:15 +09:00

303 lines
12 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.
"""SlotService unit tests — transitions, inactive reset, capacity (asset-centric).
Originally ported from trex-orbit (dcy-001 FR-3); rewritten at the dcy-020
cutover (BAT-030): permanent asset slots, 5-state machine, instance-scoped
capacity. Covers REQ-002 + REQ-057/REQ-059.
"""
from __future__ import annotations
import threading
import time
from uuid import uuid4
import pytest
from trex_core.domain.slot import (
SlotNotFoundError,
SlotService,
SlotState,
SlotStateMismatchError,
SlotTransitionError,
)
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
@pytest.fixture
def repo():
return InMemorySlotRepository()
@pytest.fixture
def service(repo):
return SlotService(repo, instance_key=INST)
def _seed(repo, n=1):
"""Create n permanent asset slots (inactive) via universe reconcile."""
repo.reconcile_universe(INST, {f"pf-{i}" for i in range(n)})
return sorted(repo.list_slots(INST), key=lambda s: s.portfolio_id)
# ----------------------------------------------------------------------------
# transit_slot — happy paths
# ----------------------------------------------------------------------------
class TestTransitHappyPath:
def test_inactive_to_pending_buy_records_fields(self, service, repo):
slot = _seed(repo)[0]
updated = service.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="orb_breakout",
actor="buy_decision_thread",
symbol="005930",
qty=10,
allocated_capital=1_000_000,
strategy_id="orb_v1",
)
assert updated.status is SlotState.PENDING_BUY
assert updated.symbol == "005930"
assert updated.qty == 10
assert updated.strategy_id == "orb_v1"
assert updated.portfolio_id == slot.portfolio_id # asset binding kept
# log appended
log = repo.transition_log_for(slot.slot_id)
assert len(log) == 1
assert log[0].from_state is SlotState.INACTIVE
assert log[0].to_state is SlotState.PENDING_BUY
assert log[0].reason == "orb_breakout"
assert log[0].actor == "buy_decision_thread"
def test_pending_buy_to_holding(self, service, repo):
slot = _seed(repo)[0]
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930",
)
service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="order_exec",
avg_price=70_000,
)
cur = repo.fetch_slot(slot.slot_id)
assert cur.status is SlotState.HOLDING
assert cur.avg_price == 70_000
# dcy-012: limit_price (pending order limit) is cleared on entering holding.
assert cur.limit_price is None
def test_buy_rollback_resets_occupancy_keeps_asset_identity(self, service, repo):
"""pending_buy -> inactive (rejection rollback): position/order fields
are cleared while the permanent slot keeps its asset binding."""
slot = _seed(repo)[0]
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="orb_breakout", actor="buy", symbol="005930", qty=10,
allocated_capital=1_000_000, strategy_id="orb_v1",
)
rolled = service.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
reason="broker_reject:001", actor="order_exec",
)
assert rolled.status is SlotState.INACTIVE
assert rolled.qty is None
assert rolled.allocated_capital is None
assert rolled.strategy_id is None
assert rolled.limit_price is None
assert rolled.broker_order_id is None
# asset identity preserved (dcy-020/REQ-056 permanent slot)
assert rolled.portfolio_id == slot.portfolio_id
assert rolled.instance_key == INST
def test_sell_fill_returns_to_inactive_and_clears_position(self, service, repo):
slot = _seed(repo)[0]
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a", symbol="005930", qty=10)
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a", avg_price=70_000)
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="a")
done = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL,
SlotState.INACTIVE, reason="sell_filled", actor="a")
assert done.status is SlotState.INACTIVE
assert done.qty is None and done.avg_price is None
assert done.portfolio_id == slot.portfolio_id
# the same asset slot can activate again (permanent reuse)
again = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="re-entry", actor="a", symbol="005930",
)
assert again.status is SlotState.PENDING_BUY
# ----------------------------------------------------------------------------
# transit_slot — rejections
# ----------------------------------------------------------------------------
class TestTransitRejections:
def test_disallowed_transition_raises(self, service, repo):
slot = _seed(repo)[0]
with pytest.raises(SlotTransitionError):
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.HOLDING,
reason="x", actor="a",
)
def test_state_mismatch_raises_when_current_state_drifted(self, service, repo):
"""Race detection — if slot already advanced, transit_slot from old state fails."""
slot = _seed(repo)[0]
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
with pytest.raises(SlotStateMismatchError):
# caller still thinks status=inactive but it's pending_buy
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
def test_unknown_slot_raises(self, service):
with pytest.raises(SlotNotFoundError):
service.transit_slot(
uuid4(), SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
# ----------------------------------------------------------------------------
# operator error → inactive/holding (REQ-004)
# ----------------------------------------------------------------------------
class TestOperatorResolve:
def test_error_to_inactive_via_operator(self, service, repo):
slot = _seed(repo)[0]
# force into error
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
reason="reconcile_mismatch", actor="system",
)
result = service.transit_slot(
slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
reason="broker confirmed no position", actor="operator:alice",
)
assert result.status is SlotState.INACTIVE
log = repo.transition_log_for(slot.slot_id)
assert log[-1].actor == "operator:alice"
assert "broker confirmed" in log[-1].reason
def test_error_to_pending_buy_rejected(self, service, repo):
slot = _seed(repo)[0]
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
reason="r", actor="s",
)
with pytest.raises(SlotTransitionError):
service.transit_slot(
slot.slot_id, SlotState.ERROR, SlotState.PENDING_BUY,
reason="r", actor="operator:alice",
)
# ----------------------------------------------------------------------------
# compute_slot_capacity (REQ-002 AC-6; dcy-020/REQ-059)
# ----------------------------------------------------------------------------
class TestCapacity:
def test_all_inactive_no_cap(self, service, repo):
_seed(repo, 10)
cap = service.compute_slot_capacity()
assert cap.inactive_count == 10
assert cap.holding_count == 0
assert cap.pending_count == 0
assert cap.active_count == 0
assert cap.available_count == 10
assert cap.total_slots == 10
assert cap.scarcity_ratio == 0.0
def test_mixed_states_under_m_cap(self, service, repo):
slots = _seed(repo, 10)
# move 3 to pending_buy + 2 to holding
for s in slots[:3]:
service.transit_slot(
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
for s in slots[3:5]:
service.transit_slot(
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
service.transit_slot(
s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a",
)
cap = service.compute_slot_capacity(max_active_slots=8)
assert cap.inactive_count == 5
assert cap.pending_count == 3
assert cap.holding_count == 2
assert cap.active_count == 5
assert cap.available_count == 3 # M(8) − active(5)
assert cap.total_slots == 8
assert cap.scarcity_ratio == pytest.approx(1 - 3 / 8)
def test_over_cap_clamps_available_to_zero(self, service, repo):
slots = _seed(repo, 3)
for s in slots:
service.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a")
cap = service.compute_slot_capacity(max_active_slots=2)
assert cap.active_count == 3
assert cap.available_count == 0
assert cap.scarcity_ratio == 1.0
def test_empty_instance(self, service):
cap = service.compute_slot_capacity()
assert cap.total_slots == 0
assert cap.available_count == 0
assert cap.scarcity_ratio == 0.0
# ----------------------------------------------------------------------------
# advisory lock serialization (REQ-002 AC-5)
# ----------------------------------------------------------------------------
class TestAdvisoryLockSerialization:
def test_concurrent_transitions_are_serialized(self, service, repo):
"""Two threads competing for the same slot — one must observe state mismatch."""
slot = _seed(repo)[0]
results: dict[str, Exception | None] = {}
def worker(name: str):
try:
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason=f"r-{name}", actor=name,
)
results[name] = None
except Exception as exc: # noqa: BLE001
results[name] = exc
t1 = threading.Thread(target=worker, args=("buy_thread",))
t2 = threading.Thread(target=worker, args=("sell_thread",))
t1.start()
t2.start()
t1.join()
t2.join()
# exactly one succeeded; the other observed state mismatch
successes = [k for k, v in results.items() if v is None]
failures = [k for k, v in results.items() if v is not None]
assert len(successes) == 1
assert len(failures) == 1
assert isinstance(failures[0] and results[failures[0]], SlotStateMismatchError)
def test_lock_hold_time_under_50ms(self, service, repo):
"""REQ-002 AC-5 numeric: transition under the in-memory repo finishes < 50 ms."""
slot = _seed(repo)[0]
t0 = time.perf_counter()
service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="a",
)
elapsed_ms = (time.perf_counter() - t0) * 1000
assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms"