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:
@@ -1,7 +1,6 @@
|
||||
"""Price threshold field tests (REQ-035 / BAT-010)."""
|
||||
"""Price threshold field tests (REQ-035 / BAT-010; asset-centric BAT-030)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -11,10 +10,7 @@ import dataclasses
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot, SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -24,14 +20,19 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _inactive_slot(repo):
|
||||
repo.reconcile_universe(INST, {"pf-1"})
|
||||
return repo.find_slot_by_portfolio(INST, "pf-1")
|
||||
|
||||
|
||||
class TestSlotModelPriceFields:
|
||||
def test_price_fields_default_to_none(self):
|
||||
"""AC-1: stop_loss_price and take_profit_price default to None."""
|
||||
from uuid import uuid4
|
||||
slot = Slot(slot_id=uuid4(), trading_date=date(2026, 6, 7), status=SlotState.IDLE)
|
||||
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
assert slot.stop_loss_price is None
|
||||
assert slot.take_profit_price is None
|
||||
|
||||
@@ -48,12 +49,11 @@ class TestSlotModelPriceFields:
|
||||
|
||||
|
||||
class TestTransitSlotPreservesPriceFields:
|
||||
def test_price_fields_set_on_pending_buy(self, service, repo, trading_day):
|
||||
"""AC-2/AC-4: price threshold fields are stored in the slot on IDLE→PENDING_BUY."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_price_fields_set_on_pending_buy(self, service, repo):
|
||||
"""AC-2/AC-4: price threshold fields are stored on INACTIVE→PENDING_BUY."""
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
@@ -61,12 +61,11 @@ class TestTransitSlotPreservesPriceFields:
|
||||
assert pending.stop_loss_price == Decimal("48000")
|
||||
assert pending.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_price_fields_preserved_through_holding(self, service, repo, trading_day):
|
||||
def test_price_fields_preserved_through_holding(self, service, repo):
|
||||
"""Price fields survive PENDING_BUY→HOLDING fill transition."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
@@ -78,29 +77,27 @@ class TestTransitSlotPreservesPriceFields:
|
||||
assert holding.stop_loss_price == Decimal("48000")
|
||||
assert holding.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_idle_reset_clears_price_fields(self, service, repo, trading_day):
|
||||
"""PENDING_BUY→IDLE rollback clears stop_loss_price and take_profit_price."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_inactive_reset_clears_price_fields(self, service, repo):
|
||||
"""PENDING_BUY→INACTIVE rollback clears stop_loss_price and take_profit_price."""
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
)
|
||||
rolled_back = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason="rejected", actor="test",
|
||||
)
|
||||
assert rolled_back.stop_loss_price is None
|
||||
assert rolled_back.take_profit_price is None
|
||||
|
||||
def test_no_price_fields_sets_none(self, service, repo, trading_day):
|
||||
def test_no_price_fields_sets_none(self, service, repo):
|
||||
"""AC-5: transit without price fields leaves them as None."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
assert pending.stop_loss_price is None
|
||||
|
||||
+106
-151
@@ -1,12 +1,13 @@
|
||||
"""SlotService unit tests — pre-allocation, transitions, atomic counter, capacity.
|
||||
"""SlotService unit tests — transitions, inactive reset, capacity (asset-centric).
|
||||
|
||||
Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 AC-1 ~ AC-6.
|
||||
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 datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -20,10 +21,7 @@ from trex_core.domain.slot import (
|
||||
)
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 5, 24)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -33,47 +31,24 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _one_idle(repo, trading_day):
|
||||
return repo.find_idle_slot(trading_day)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# preallocate
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestPreallocate:
|
||||
def test_creates_idle_slots(self, service, repo, trading_day):
|
||||
inserted = service.preallocate_daily_slots(trading_day, 10)
|
||||
assert inserted == 10
|
||||
assert len(repo.list_slots(trading_day)) == 10
|
||||
assert all(s.status is SlotState.IDLE for s in repo.list_slots(trading_day))
|
||||
|
||||
def test_idempotent_second_call_returns_zero(self, service, repo, trading_day):
|
||||
"""REQ-002 AC-4: same trading_date → 0 inserted on repeat."""
|
||||
service.preallocate_daily_slots(trading_day, 5)
|
||||
inserted = service.preallocate_daily_slots(trading_day, 5)
|
||||
assert inserted == 0
|
||||
assert len(repo.list_slots(trading_day)) == 5
|
||||
|
||||
def test_zero_or_negative_count_rejected(self, service, trading_day):
|
||||
with pytest.raises(ValueError):
|
||||
service.preallocate_daily_slots(trading_day, 0)
|
||||
with pytest.raises(ValueError):
|
||||
service.preallocate_daily_slots(trading_day, -1)
|
||||
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_idle_to_pending_buy_records_fields(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_inactive_to_pending_buy_records_fields(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.IDLE,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="orb_breakout",
|
||||
actor="buy_decision_thread",
|
||||
@@ -86,19 +61,19 @@ class TestTransitHappyPath:
|
||||
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.IDLE
|
||||
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, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_pending_buy_to_holding(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930",
|
||||
)
|
||||
service.transit_slot(
|
||||
@@ -112,135 +87,108 @@ class TestTransitHappyPath:
|
||||
# dcy-012: limit_price (pending order limit) is cleared on entering holding.
|
||||
assert cur.limit_price is None
|
||||
|
||||
def test_pending_buy_to_idle_rollback_preserves_symbol_until_recovery(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""pending_buy -> idle (rejection rollback) is allowed by state machine.
|
||||
|
||||
Note: this rollback does NOT bump daily_symbol_entries (only closed→idle does).
|
||||
"""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
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.IDLE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930",
|
||||
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",
|
||||
)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
rolled = service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason="broker_reject:001", actor="order_exec",
|
||||
)
|
||||
assert repo.get_symbol_entry_count(trading_day, "005930") == 0
|
||||
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
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# transit_slot — closed→idle recovery REMOVED (dcy-020 / REQ-057)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestClosedRecoveryRemoved:
|
||||
def test_closed_to_idle_is_rejected(self, service, repo, trading_day):
|
||||
"""dcy-020: closed→idle recovery is removed (idle/closed both = inactive)."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
# drive to closed
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.transit_slot(slot.slot_id, SlotState.CLOSED, SlotState.IDLE,
|
||||
reason="recovery", actor="a")
|
||||
|
||||
def test_reuse_via_closed_to_pending_buy_reallocation(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""Asset-centric reuse: a closed slot is reactivated via closed→pending_buy
|
||||
(dcy-019 reallocation), not closed→idle recovery."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a", symbol="000660",
|
||||
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 realloc.status is SlotState.PENDING_BUY
|
||||
assert realloc.symbol == "000660" # clean-slate
|
||||
assert realloc.entry_count == 2 # cumulative
|
||||
assert again.status is SlotState.PENDING_BUY
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# transit_slot — rejections
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestTransitRejections:
|
||||
def test_disallowed_transition_raises(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_disallowed_transition_raises(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.HOLDING,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.HOLDING,
|
||||
reason="x", actor="a",
|
||||
)
|
||||
|
||||
def test_state_mismatch_raises_when_current_state_drifted(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
def test_state_mismatch_raises_when_current_state_drifted(self, service, repo):
|
||||
"""Race detection — if slot already advanced, transit_slot from old state fails."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
with pytest.raises(SlotStateMismatchError):
|
||||
# caller still thinks status=idle but it's pending_buy
|
||||
# caller still thinks status=inactive but it's pending_buy
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
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.IDLE, SlotState.PENDING_BUY,
|
||||
uuid4(), SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# operator error → idle/holding (REQ-004)
|
||||
# operator error → inactive/holding (REQ-004)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestOperatorResolve:
|
||||
def test_error_to_idle_via_operator(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_error_to_inactive_via_operator(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
# force into error
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
|
||||
reason="reconcile_mismatch", actor="system",
|
||||
)
|
||||
result = service.transit_slot(
|
||||
slot.slot_id, SlotState.ERROR, SlotState.IDLE,
|
||||
slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
|
||||
reason="broker confirmed no position", actor="operator:alice",
|
||||
)
|
||||
assert result.status is SlotState.IDLE
|
||||
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, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_error_to_pending_buy_rejected(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
|
||||
reason="r", actor="s",
|
||||
)
|
||||
with pytest.raises(SlotTransitionError):
|
||||
@@ -251,62 +199,77 @@ class TestOperatorResolve:
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# compute_slot_capacity (REQ-002 AC-6)
|
||||
# compute_slot_capacity (REQ-002 AC-6; dcy-020/REQ-059)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestCapacity:
|
||||
def test_all_idle(self, service, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 10)
|
||||
cap = service.compute_slot_capacity(trading_day, 10)
|
||||
assert cap.idle_count == 10
|
||||
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(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 10)
|
||||
def test_mixed_states_under_m_cap(self, service, repo):
|
||||
slots = _seed(repo, 10)
|
||||
# move 3 to pending_buy + 2 to holding
|
||||
slots = repo.list_slots(trading_day)
|
||||
for s in slots[:3]:
|
||||
service.transit_slot(
|
||||
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
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.IDLE, SlotState.PENDING_BUY,
|
||||
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(trading_day, 10)
|
||||
assert cap.idle_count == 5
|
||||
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.scarcity_ratio == pytest.approx(0.5)
|
||||
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_zero_total_rejected(self, service, trading_day):
|
||||
with pytest.raises(ValueError):
|
||||
service.compute_slot_capacity(trading_day, 0)
|
||||
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, trading_day):
|
||||
def test_concurrent_transitions_are_serialized(self, service, repo):
|
||||
"""Two threads competing for the same slot — one must observe state mismatch."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
slot = _seed(repo)[0]
|
||||
|
||||
results: dict[str, Exception | None] = {}
|
||||
|
||||
def worker(name: str):
|
||||
try:
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason=f"r-{name}", actor=name,
|
||||
)
|
||||
results[name] = None
|
||||
@@ -327,21 +290,13 @@ class TestAdvisoryLockSerialization:
|
||||
assert len(failures) == 1
|
||||
assert isinstance(failures[0] and results[failures[0]], SlotStateMismatchError)
|
||||
|
||||
def test_lock_hold_time_under_50ms(self, service, trading_day):
|
||||
def test_lock_hold_time_under_50ms(self, service, repo):
|
||||
"""REQ-002 AC-5 numeric: transition under the in-memory repo finishes < 50 ms."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = list(service.compute_slot_capacity.__self__._repo.list_slots(trading_day))[0] # type: ignore[attr-defined]
|
||||
slot = _seed(repo)[0]
|
||||
t0 = time.perf_counter()
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
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"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# get_today_entry_count (REQ-001 + REQ-002 boundary)
|
||||
# ----------------------------------------------------------------------------
|
||||
def test_get_today_entry_count_for_unseen_symbol_is_zero(service, trading_day):
|
||||
assert service.get_today_entry_count(trading_day, "999999") == 0
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""SlotService.sell() unit tests (REQ-036 / BAT-010)."""
|
||||
"""SlotService.sell() unit tests (REQ-036 / BAT-010; asset-centric BAT-030)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import (
|
||||
@@ -13,10 +11,7 @@ from trex_core.domain.slot import (
|
||||
)
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -26,15 +21,19 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _holding_slot(service, repo, trading_day):
|
||||
def _inactive_slot(repo):
|
||||
repo.reconcile_universe(INST, {"pf-1"})
|
||||
return repo.find_slot_by_portfolio(INST, "pf-1")
|
||||
|
||||
|
||||
def _holding_slot(service, repo):
|
||||
"""Create one slot in HOLDING state."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
return service.transit_slot(
|
||||
@@ -44,16 +43,16 @@ def _holding_slot(service, repo, trading_day):
|
||||
|
||||
|
||||
class TestSell:
|
||||
def test_sell_holding_transitions_to_pending_sell(self, service, repo, trading_day):
|
||||
def test_sell_holding_transitions_to_pending_sell(self, service, repo):
|
||||
"""AC-1: sell() on a holding slot transitions it to pending_sell."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
updated = repo.fetch_slot(holding.slot_id)
|
||||
assert updated.status is SlotState.PENDING_SELL
|
||||
|
||||
def test_sell_records_transition_log(self, service, repo, trading_day):
|
||||
def test_sell_records_transition_log(self, service, repo):
|
||||
"""sell() appends a transition log entry."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
log = repo.transition_log_for(holding.slot_id)
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
@@ -61,9 +60,9 @@ class TestSell:
|
||||
assert sell_entries[0].actor == "safety-sell"
|
||||
assert sell_entries[0].reason == "stop_loss"
|
||||
|
||||
def test_sell_idempotent_on_pending_sell(self, service, repo, trading_day):
|
||||
def test_sell_idempotent_on_pending_sell(self, service, repo):
|
||||
"""AC-2: sell() on already-pending_sell slot returns silently, no second transition."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Call again — must not raise and must not add another transition
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
@@ -71,16 +70,15 @@ class TestSell:
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
assert len(sell_entries) == 1 # exactly one, not two
|
||||
|
||||
def test_sell_idle_slot_raises_transition_error(self, service, repo, trading_day):
|
||||
"""AC-3: sell() on an idle slot raises SlotTransitionError."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_sell_inactive_slot_raises_transition_error(self, service, repo):
|
||||
"""AC-3: sell() on an inactive slot raises SlotTransitionError."""
|
||||
slot = _inactive_slot(repo)
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.sell(idle.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
service.sell(slot.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Slot state unchanged
|
||||
assert repo.fetch_slot(idle.slot_id).status is SlotState.IDLE
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.INACTIVE
|
||||
|
||||
def test_sell_unknown_slot_raises_not_found(self, service, trading_day):
|
||||
def test_sell_unknown_slot_raises_not_found(self, service):
|
||||
"""sell() with unknown slot_id raises SlotNotFoundError."""
|
||||
from uuid import uuid4
|
||||
with pytest.raises(SlotNotFoundError):
|
||||
@@ -88,13 +86,12 @@ class TestSell:
|
||||
|
||||
|
||||
class TestSellPreservesPriceFields:
|
||||
def test_sell_preserves_stop_loss_price(self, service, repo, trading_day):
|
||||
def test_sell_preserves_stop_loss_price(self, service, repo):
|
||||
"""Price threshold fields survive the sell transition."""
|
||||
from decimal import Decimal
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("45000"),
|
||||
take_profit_price=Decimal("60000"),
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""dcy-019 / BAT-025: slot-scoped entry_count + closed-slot reallocation.
|
||||
|
||||
Covers REQ-002 (entry_count field + closed→pending_buy clean-slate transition)
|
||||
and REQ-006 (find_allocatable_slot selector ordering over idle ∪ closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 9)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
def _cycle_to_closed(service, slot_id, symbol="005930"):
|
||||
"""Drive a slot idle→pending_buy→holding→pending_sell→closed."""
|
||||
service.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol=symbol)
|
||||
service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
service.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
service.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# entry_count increment (REQ-002 FR-4)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestEntryCount:
|
||||
def test_new_slot_starts_at_zero(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
assert slot.entry_count == 0
|
||||
|
||||
def test_increments_on_each_pending_buy(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930",
|
||||
)
|
||||
assert updated.entry_count == 1
|
||||
|
||||
def test_not_incremented_on_non_buy_transitions(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
held = service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a")
|
||||
# holding entry does not bump the per-slot entry counter
|
||||
assert held.entry_count == 1
|
||||
|
||||
def test_preserved_while_closed(self, service, repo, trading_day):
|
||||
# dcy-020: closed→idle recovery removed; entry_count persists on the
|
||||
# closed slot until the next allocation (closed→pending_buy) bumps it.
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id)
|
||||
closed = repo.fetch_slot(slot.slot_id)
|
||||
assert closed.status is SlotState.CLOSED
|
||||
assert closed.entry_count == 1 # slot-scoped cumulative, preserved
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# closed → pending_buy direct reallocation (REQ-002 FR / state machine #9)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestClosedReallocation:
|
||||
def test_closed_to_pending_buy_clean_slate_and_bumps_count(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id, symbol="005930")
|
||||
closed = repo.fetch_slot(slot.slot_id)
|
||||
assert closed.status is SlotState.CLOSED
|
||||
assert closed.entry_count == 1
|
||||
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a",
|
||||
symbol="000660", qty=5, allocated_capital=500_000,
|
||||
limit_price=120_000, strategy_id="orb_v2",
|
||||
)
|
||||
# new entry's fields applied
|
||||
assert realloc.status is SlotState.PENDING_BUY
|
||||
assert realloc.symbol == "000660"
|
||||
assert realloc.qty == 5
|
||||
assert realloc.limit_price == 120_000
|
||||
assert realloc.strategy_id == "orb_v2"
|
||||
# entry_count cumulative across reallocation (1 → 2)
|
||||
assert realloc.entry_count == 2
|
||||
|
||||
def test_closed_to_pending_buy_clears_prior_position_when_not_supplied(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""A reallocation that omits a field must not leak the prior cycle's value."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
# first cycle sets stop_loss/take_profit, then closes
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930",
|
||||
stop_loss_price=60_000, take_profit_price=80_000)
|
||||
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")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
# reallocate WITHOUT thresholds → must be cleared, not inherited
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a", symbol="000660",
|
||||
)
|
||||
assert realloc.symbol == "000660"
|
||||
assert realloc.stop_loss_price is None
|
||||
assert realloc.take_profit_price is None
|
||||
assert realloc.avg_price is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# find_allocatable_slot selector ordering (REQ-006, OQ-2)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestAllocatableSelector:
|
||||
def test_returns_none_when_no_idle_or_closed(self, service, repo, trading_day):
|
||||
assert repo.find_allocatable_slot(trading_day) is None
|
||||
|
||||
def test_prefers_idle_when_only_idle_present(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 3)
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand is not None and cand.status is SlotState.IDLE
|
||||
|
||||
def test_includes_closed_when_no_idle_remains(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id)
|
||||
# no idle left, but the closed slot is allocatable
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand is not None and cand.status is SlotState.CLOSED
|
||||
|
||||
def test_orders_by_entry_count_ascending(self, service, repo, trading_day):
|
||||
"""A closed slot (entry_count=1) loses to a fresh idle slot (entry_count=0)."""
|
||||
service.preallocate_daily_slots(trading_day, 2)
|
||||
slots = repo.list_slots(trading_day)
|
||||
_cycle_to_closed(service, slots[0].slot_id) # this one now entry_count=1, closed
|
||||
# the still-idle slot has entry_count 0 and must be chosen first
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand.entry_count == 0
|
||||
assert cand.status is SlotState.IDLE
|
||||
@@ -1,7 +1,7 @@
|
||||
"""State machine unit tests — asset-centric redefinition (dcy-020 / REQ-057).
|
||||
"""State machine unit tests — asset-centric (dcy-020 / REQ-057, BAT-030).
|
||||
|
||||
`inactive` is the canonical no-position state; `idle`/`closed` are aliases that
|
||||
normalize to `inactive`. `closed->idle` recovery is removed. Covers REQ-057.
|
||||
`inactive` is the only no-position state. The pool-model `idle`/`closed`
|
||||
aliases were removed at the cutover (D2); migration 010 rewrites legacy rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,29 +12,28 @@ from trex_core.domain.slot.state import (
|
||||
SLOT_ADVISORY_LOCK_ID,
|
||||
SlotState,
|
||||
is_allowed_transition,
|
||||
normalize_state,
|
||||
slot_advisory_lock_id,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_state_members():
|
||||
"""dcy-020: 5 canonical + 2 aliases(idle/closed) = 7 enum members."""
|
||||
"""dcy-020 (BAT-030): 5 canonical members — idle/closed aliases removed."""
|
||||
assert {s.value for s in SlotState} == {
|
||||
"inactive",
|
||||
"idle",
|
||||
"pending_buy",
|
||||
"holding",
|
||||
"pending_sell",
|
||||
"closed",
|
||||
"error",
|
||||
}
|
||||
|
||||
|
||||
def test_idle_and_closed_normalize_to_inactive():
|
||||
assert normalize_state(SlotState.IDLE) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.CLOSED) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.INACTIVE) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.HOLDING) is SlotState.HOLDING
|
||||
def test_legacy_alias_values_rejected():
|
||||
"""`idle`/`closed` are no longer valid SlotState values (migration 010
|
||||
rewrites any remaining DB rows before this code reads them)."""
|
||||
with pytest.raises(ValueError):
|
||||
SlotState("idle")
|
||||
with pytest.raises(ValueError):
|
||||
SlotState("closed")
|
||||
|
||||
|
||||
def test_advisory_lock_id_is_stable_constant():
|
||||
@@ -56,27 +55,20 @@ def test_slot_advisory_lock_id_is_instance_scoped():
|
||||
@pytest.mark.parametrize(
|
||||
"from_state,to_state",
|
||||
[
|
||||
# canonical (inactive-based)
|
||||
# canonical lifecycle
|
||||
(SlotState.INACTIVE, SlotState.PENDING_BUY),
|
||||
(SlotState.PENDING_BUY, SlotState.HOLDING),
|
||||
(SlotState.PENDING_BUY, SlotState.INACTIVE),
|
||||
(SlotState.HOLDING, SlotState.PENDING_SELL),
|
||||
(SlotState.PENDING_SELL, SlotState.INACTIVE),
|
||||
(SlotState.PENDING_SELL, SlotState.HOLDING),
|
||||
# alias equivalence: idle/closed normalize to inactive
|
||||
(SlotState.IDLE, SlotState.PENDING_BUY), # idle->pending_buy
|
||||
(SlotState.CLOSED, SlotState.PENDING_BUY), # closed->pending_buy (reallocation)
|
||||
(SlotState.PENDING_SELL, SlotState.CLOSED), # sell filled (closed alias)
|
||||
(SlotState.PENDING_BUY, SlotState.IDLE), # buy rollback (idle alias)
|
||||
# to error (every non-error)
|
||||
(SlotState.INACTIVE, SlotState.ERROR),
|
||||
(SlotState.PENDING_BUY, SlotState.ERROR),
|
||||
(SlotState.HOLDING, SlotState.ERROR),
|
||||
(SlotState.PENDING_SELL, SlotState.ERROR),
|
||||
(SlotState.CLOSED, SlotState.ERROR),
|
||||
# operator resolve out of error
|
||||
(SlotState.ERROR, SlotState.INACTIVE),
|
||||
(SlotState.ERROR, SlotState.IDLE), # alias
|
||||
(SlotState.ERROR, SlotState.HOLDING),
|
||||
],
|
||||
)
|
||||
@@ -87,16 +79,14 @@ def test_allowed_transition(from_state: SlotState, to_state: SlotState):
|
||||
@pytest.mark.parametrize(
|
||||
"from_state,to_state",
|
||||
[
|
||||
# closed->idle recovery REMOVED (normalizes to inactive self-loop)
|
||||
(SlotState.CLOSED, SlotState.IDLE),
|
||||
# skipping intermediate states
|
||||
(SlotState.INACTIVE, SlotState.HOLDING),
|
||||
(SlotState.INACTIVE, SlotState.PENDING_SELL),
|
||||
(SlotState.HOLDING, SlotState.INACTIVE),
|
||||
(SlotState.HOLDING, SlotState.CLOSED),
|
||||
(SlotState.HOLDING, SlotState.PENDING_BUY),
|
||||
(SlotState.PENDING_BUY, SlotState.PENDING_SELL),
|
||||
(SlotState.PENDING_SELL, SlotState.PENDING_BUY),
|
||||
# error has restricted exits — only inactive(+aliases) and holding
|
||||
# error has restricted exits — only inactive and holding
|
||||
(SlotState.ERROR, SlotState.PENDING_BUY),
|
||||
(SlotState.ERROR, SlotState.PENDING_SELL),
|
||||
(SlotState.ERROR, SlotState.ERROR),
|
||||
@@ -107,14 +97,11 @@ def test_disallowed_transition(from_state: SlotState, to_state: SlotState):
|
||||
|
||||
|
||||
def test_self_loops_are_not_allowed():
|
||||
"""No state may transition to itself (including inactive and its aliases)."""
|
||||
"""No state may transition to itself."""
|
||||
for s in SlotState:
|
||||
assert not is_allowed_transition(s, s)
|
||||
# alias self-loops also rejected
|
||||
assert not is_allowed_transition(SlotState.IDLE, SlotState.CLOSED)
|
||||
assert not is_allowed_transition(SlotState.CLOSED, SlotState.IDLE)
|
||||
|
||||
|
||||
def test_allowed_transitions_set_size():
|
||||
"""6 base (inactive-canonical) + 4 to-error + 2 operator-resolve = 12."""
|
||||
"""6 base + 4 to-error + 2 operator-resolve = 12."""
|
||||
assert len(ALLOWED_TRANSITIONS) == 12
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""dcy-020 / REQ-058 (BAT-028): universe reconcile lifecycle (in-memory)."""
|
||||
"""dcy-020 / REQ-058 (BAT-028; BAT-030 trading_date-free): universe reconcile lifecycle (in-memory)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 10)
|
||||
INST = "inst-1"
|
||||
|
||||
|
||||
@@ -18,17 +15,17 @@ def repo():
|
||||
|
||||
|
||||
def test_creates_asset_slots_idempotently(repo):
|
||||
r1 = repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r1 = repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert r1["created"] == 2
|
||||
# idempotent — second pass creates nothing
|
||||
r2 = repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r2 = repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert r2["created"] == 0
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.IDLE
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_soft_retire_inactive_asset_that_left_universe(repo):
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, DAY, {"pf-a"}) # pf-b left
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, {"pf-a"}) # pf-b left
|
||||
assert r["retired"] == 1
|
||||
slot_b = repo.find_slot_by_portfolio(INST, "pf-b")
|
||||
assert slot_b.retired_at is not None # soft-retired, row preserved
|
||||
@@ -36,28 +33,28 @@ def test_soft_retire_inactive_asset_that_left_universe(repo):
|
||||
|
||||
def test_tombstone_holding_asset_that_left_universe(repo):
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a"})
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
r = repo.reconcile_universe(INST, DAY, set()) # pf-a left while holding
|
||||
r = repo.reconcile_universe(INST, set()) # pf-a left while holding
|
||||
assert r["tombstoned"] == 1 and r["retired"] == 0
|
||||
held = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
assert held.status is SlotState.HOLDING and held.retired_at is None # protected
|
||||
|
||||
|
||||
def test_allow_retire_false_skips_retire(repo):
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, DAY, {"pf-a"}, allow_retire=False)
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, {"pf-a"}, allow_retire=False)
|
||||
assert r["retired"] == 0
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-b").retired_at is None
|
||||
|
||||
|
||||
def test_count_active(repo):
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert repo.count_active(INST) == 0
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
|
||||
@@ -66,8 +63,8 @@ def test_count_active(repo):
|
||||
|
||||
|
||||
def test_instance_isolation(repo):
|
||||
repo.reconcile_universe("inst-1", DAY, {"pf-a"})
|
||||
repo.reconcile_universe("inst-2", DAY, {"pf-a"})
|
||||
repo.reconcile_universe("inst-1", {"pf-a"})
|
||||
repo.reconcile_universe("inst-2", {"pf-a"})
|
||||
# same portfolio_id, different instances -> two distinct slots
|
||||
assert repo.find_slot_by_portfolio("inst-1", "pf-a").slot_id != \
|
||||
repo.find_slot_by_portfolio("inst-2", "pf-a").slot_id
|
||||
|
||||
Reference in New Issue
Block a user