"""SlotService.sell() unit tests (REQ-036 / BAT-010; asset-centric BAT-030).""" from __future__ import annotations import pytest from trex_core.domain.slot import ( SlotNotFoundError, SlotService, SlotState, 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 _inactive_slot(repo): repo.reconcile_universe(INST, {"pf-1": "SYM"}) return repo.find_slot_by_portfolio(INST, "pf-1") def _holding_slot(service, repo): """Create one slot in HOLDING state.""" slot = _inactive_slot(repo) pending = service.transit_slot( slot.slot_id, SlotState.INACTIVE, 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): """AC-1: sell() on a holding slot transitions it to pending_sell.""" 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): """sell() appends a transition log entry.""" 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] 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): """AC-2: sell() on already-pending_sell slot returns silently, no second transition.""" 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") 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_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(slot.slot_id, actor="safety-sell", reason="stop_loss") # Slot state unchanged assert repo.fetch_slot(slot.slot_id).status is SlotState.INACTIVE 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): service.sell(uuid4(), actor="safety-sell", reason="stop_loss") class TestSellPreservesPriceFields: def test_sell_preserves_stop_loss_price(self, service, repo): """Price threshold fields survive the sell transition.""" from decimal import Decimal slot = _inactive_slot(repo) pending = service.transit_slot( slot.slot_id, SlotState.INACTIVE, 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")