"""SlotService.sell() unit tests (REQ-036 / BAT-010).""" from __future__ import annotations from datetime import date import pytest from trex_core.domain.slot import ( SlotNotFoundError, SlotService, SlotState, SlotTransitionError, ) from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository @pytest.fixture def trading_day(): return date(2026, 6, 7) @pytest.fixture def repo(): return InMemorySlotRepository() @pytest.fixture def service(repo): return SlotService(repo) def _holding_slot(service, repo, trading_day): """Create one slot in HOLDING state.""" service.preallocate_daily_slots(trading_day, 1) idle = repo.find_idle_slot(trading_day) pending = service.transit_slot( idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, reason="buy", actor="test", symbol="005930", ) return service.transit_slot( pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="test", avg_price=50000.0, qty=1.0, ) class TestSell: def test_sell_holding_transitions_to_pending_sell(self, service, repo, trading_day): """AC-1: sell() on a holding slot transitions it to pending_sell.""" holding = _holding_slot(service, repo, trading_day) 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): """sell() appends a transition log entry.""" holding = _holding_slot(service, repo, trading_day) 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] assert len(sell_entries) == 1 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): """AC-2: sell() on already-pending_sell slot returns silently, no second transition.""" holding = _holding_slot(service, repo, trading_day) 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") log = repo.transition_log_for(holding.slot_id) 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) with pytest.raises(SlotTransitionError): service.sell(idle.slot_id, actor="safety-sell", reason="stop_loss") # Slot state unchanged assert repo.fetch_slot(idle.slot_id).status is SlotState.IDLE def test_sell_unknown_slot_raises_not_found(self, service, trading_day): """sell() with unknown slot_id raises SlotNotFoundError.""" from uuid import uuid4 with pytest.raises(SlotNotFoundError): service.sell(uuid4(), actor="safety-sell", reason="stop_loss") class TestSellPreservesPriceFields: def test_sell_preserves_stop_loss_price(self, service, repo, trading_day): """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) pending = service.transit_slot( idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY, reason="buy", actor="test", symbol="005930", stop_loss_price=Decimal("45000"), take_profit_price=Decimal("60000"), ) holding = service.transit_slot( pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="test", avg_price=50000.0, qty=1.0, ) assert holding.stop_loss_price == Decimal("45000") assert holding.take_profit_price == Decimal("60000") service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss") updated = repo.fetch_slot(holding.slot_id) assert updated.stop_loss_price == Decimal("45000") assert updated.take_profit_price == Decimal("60000")