"""Unit tests for services.signal_engine.exit_engine — Exit Engine. Tests stop-loss triggers, target-1 partial exits, target-2 full exits, trailing stop activation/ratchet behavior, priority ordering, empty positions, and fallback to position.current_price when ticker is absent from current_prices. Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 """ from __future__ import annotations from services.signal_engine.config import ExitConfig from services.signal_engine.exit_engine import evaluate_exits from services.signal_engine.models import ( ExitType, OpenPositionState, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _default_config() -> ExitConfig: return ExitConfig(trailing_stop_atr_multiplier=2.0) def _position( *, position_id: str = "pos-1", ticker: str = "AAPL", entry_price: float = 100.0, current_price: float = 110.0, stop_loss: float = 90.0, target_1: float = 115.0, target_2: float = 130.0, trailing_stop: float | None = None, partial_exit_done: bool = False, atr: float | None = 5.0, ) -> OpenPositionState: return OpenPositionState( position_id=position_id, ticker=ticker, entry_price=entry_price, current_price=current_price, stop_loss=stop_loss, target_1=target_1, target_2=target_2, trailing_stop=trailing_stop, partial_exit_done=partial_exit_done, atr=atr, ) # =========================================================================== # 1. Stop-loss trigger (Requirement 8.1) # =========================================================================== class TestStopLoss: """Stop-loss hit → EXIT_FULL with reason 'stop_hit'.""" def test_stop_loss_exact_hit(self) -> None: """Price exactly at stop_loss triggers exit.""" pos = _position(stop_loss=90.0) signals = evaluate_exits([pos], {"AAPL": 90.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_FULL assert signals[0].reason == "stop_hit" assert signals[0].price == 90.0 assert signals[0].position_id == "pos-1" assert signals[0].ticker == "AAPL" def test_stop_loss_below(self) -> None: """Price below stop_loss triggers exit.""" pos = _position(stop_loss=90.0) signals = evaluate_exits([pos], {"AAPL": 85.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_FULL assert signals[0].reason == "stop_hit" assert signals[0].price == 85.0 def test_stop_loss_has_highest_priority(self) -> None: """Stop-loss takes priority even when target_2 is also hit. This can happen if stop_loss >= target_2 due to misconfiguration, or if the price gaps through both levels. """ # Contrived: stop_loss at 130, target_2 at 120 (misconfigured) pos = _position(stop_loss=130.0, target_2=120.0) signals = evaluate_exits([pos], {"AAPL": 125.0}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "stop_hit" # =========================================================================== # 2. Target-1 partial exit (Requirement 8.2) # =========================================================================== class TestTarget1: """Target-1 hit → EXIT_HALF with reason 'target_1_hit'.""" def test_target_1_exact_hit(self) -> None: """Price exactly at target_1 triggers partial exit.""" pos = _position(target_1=115.0) signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_HALF assert signals[0].reason == "target_1_hit" assert signals[0].price == 115.0 def test_target_1_above(self) -> None: """Price above target_1 (but below target_2) triggers partial exit.""" pos = _position(target_1=115.0, target_2=130.0) signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_HALF assert signals[0].reason == "target_1_hit" def test_target_1_not_triggered_when_partial_exit_done(self) -> None: """Target-1 is skipped when partial_exit_done is True.""" pos = _position(target_1=115.0, target_2=130.0, partial_exit_done=True, atr=5.0) # Price above target_1 but below target_2, trailing stop not hit signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) # No target_1_hit signal; trailing stop is 120 - 5*2 = 110, not hit assert len(signals) == 0 # =========================================================================== # 3. Target-2 full exit (Requirement 8.3) # =========================================================================== class TestTarget2: """Target-2 hit → EXIT_FULL with reason 'target_2_hit'.""" def test_target_2_exact_hit(self) -> None: """Price exactly at target_2 triggers full exit.""" pos = _position(target_2=130.0) signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_FULL assert signals[0].reason == "target_2_hit" assert signals[0].price == 130.0 def test_target_2_above(self) -> None: """Price above target_2 triggers full exit.""" pos = _position(target_2=130.0) signals = evaluate_exits([pos], {"AAPL": 140.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_FULL assert signals[0].reason == "target_2_hit" def test_target_2_priority_over_target_1(self) -> None: """When price hits both target_1 and target_2, target_2 wins.""" pos = _position(target_1=115.0, target_2=130.0) signals = evaluate_exits([pos], {"AAPL": 135.0}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "target_2_hit" assert signals[0].exit_type == ExitType.EXIT_FULL # =========================================================================== # 4. Trailing stop activation and ratchet (Requirement 8.4) # =========================================================================== class TestTrailingStop: """Trailing stop activates after partial exit and ratchets upward.""" def test_trailing_stop_not_active_before_partial_exit(self) -> None: """Trailing stop does not trigger when partial_exit_done is False.""" pos = _position( partial_exit_done=False, trailing_stop=108.0, atr=5.0, target_1=115.0, target_2=130.0, stop_loss=90.0, ) # Price at 107 is below trailing_stop=108, but trailing is not active signals = evaluate_exits([pos], {"AAPL": 107.0}, _default_config()) # No trailing stop signal; price is above stop_loss and below targets assert len(signals) == 0 def test_trailing_stop_computed_from_atr(self) -> None: """Trailing stop = price - ATR * multiplier when no existing stop.""" pos = _position( partial_exit_done=True, trailing_stop=None, atr=5.0, target_2=150.0, ) # Price = 120, trailing = 120 - 5*2 = 110, price > 110 → no exit signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) assert len(signals) == 0 def test_trailing_stop_ratchets_upward(self) -> None: """New trailing stop level is used only if higher than existing.""" pos = _position( partial_exit_done=True, trailing_stop=112.0, # existing high trailing stop atr=5.0, target_2=150.0, ) # Price = 120, new trailing = 120 - 10 = 110 < existing 112 # Effective trailing = 112 (ratchet keeps higher value) # Price 120 > 112 → no exit signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) assert len(signals) == 0 def test_trailing_stop_updates_when_price_advances(self) -> None: """Higher price produces higher trailing stop level.""" pos = _position( partial_exit_done=True, trailing_stop=105.0, # old trailing stop atr=5.0, target_2=200.0, ) # Price = 130, new trailing = 130 - 10 = 120 > existing 105 # Effective trailing = 120, price 130 > 120 → no exit signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config()) assert len(signals) == 0 def test_trailing_stop_no_atr_uses_existing(self) -> None: """When ATR is None, existing trailing_stop is used as-is.""" pos = _position( partial_exit_done=True, trailing_stop=115.0, atr=None, target_2=150.0, ) # Price = 120 > trailing 115 → no exit signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) assert len(signals) == 0 def test_trailing_stop_no_atr_no_existing_returns_zero(self) -> None: """When ATR is None and trailing_stop is None, effective stop is 0.""" pos = _position( partial_exit_done=True, trailing_stop=None, atr=None, target_2=150.0, ) # Effective trailing = 0.0, price 120 > 0 → no exit signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config()) assert len(signals) == 0 # =========================================================================== # 5. Trailing stop hit (Requirement 8.5) # =========================================================================== class TestTrailingStopHit: """Trailing stop hit → EXIT_FULL with reason 'trailing_stop_hit'.""" def test_trailing_stop_hit_exact(self) -> None: """Price exactly at trailing stop triggers exit.""" pos = _position( partial_exit_done=True, trailing_stop=115.0, atr=5.0, target_2=150.0, ) # Price = 115, new trailing = 115 - 10 = 105 < existing 115 # Effective trailing = 115, price 115 <= 115 → exit signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config()) assert len(signals) == 1 assert signals[0].exit_type == ExitType.EXIT_FULL assert signals[0].reason == "trailing_stop_hit" assert signals[0].price == 115.0 def test_trailing_stop_hit_below(self) -> None: """Price below trailing stop triggers exit.""" pos = _position( partial_exit_done=True, trailing_stop=115.0, atr=5.0, target_2=150.0, ) # Price = 110, new trailing = 110 - 10 = 100 < existing 115 # Effective trailing = 115, price 110 <= 115 → exit signals = evaluate_exits([pos], {"AAPL": 110.0}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "trailing_stop_hit" def test_trailing_stop_hit_computed_from_atr(self) -> None: """Trailing stop computed from ATR triggers exit when price drops.""" pos = _position( partial_exit_done=True, trailing_stop=None, # no existing trailing stop atr=3.0, target_2=150.0, ) # Price = 100, trailing = 100 - 3*2 = 94, max(0, 94) = 94 # Price 100 > 94 → no exit signals = evaluate_exits([pos], {"AAPL": 100.0}, _default_config()) assert len(signals) == 0 # Now price drops to 93 → trailing = 93 - 6 = 87, max(0, 87) = 87 # Price 93 > 87 → still no exit (trailing recomputed each call) signals2 = evaluate_exits([pos], {"AAPL": 93.0}, _default_config()) assert len(signals2) == 0 def test_trailing_stop_hit_with_high_existing_stop(self) -> None: """Existing high trailing stop triggers exit when price drops to it.""" pos = _position( partial_exit_done=True, trailing_stop=118.0, # previously ratcheted up atr=5.0, target_2=150.0, ) # Price = 117, new trailing = 117 - 10 = 107 < existing 118 # Effective trailing = 118, price 117 <= 118 → exit signals = evaluate_exits([pos], {"AAPL": 117.0}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "trailing_stop_hit" # =========================================================================== # 6. No exit when price is between stop and targets # =========================================================================== class TestNoExit: """No exit signal when price is in the safe zone.""" def test_price_between_stop_and_target_1(self) -> None: """Price above stop_loss and below target_1 → no exit.""" pos = _position(stop_loss=90.0, target_1=115.0, target_2=130.0) signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config()) assert len(signals) == 0 def test_price_just_above_stop_loss(self) -> None: """Price barely above stop_loss → no exit.""" pos = _position(stop_loss=90.0) signals = evaluate_exits([pos], {"AAPL": 90.01}, _default_config()) assert len(signals) == 0 def test_price_just_below_target_1(self) -> None: """Price barely below target_1 → no exit.""" pos = _position(target_1=115.0) signals = evaluate_exits([pos], {"AAPL": 114.99}, _default_config()) assert len(signals) == 0 # =========================================================================== # 7. Empty positions list # =========================================================================== class TestEmptyPositions: """Empty positions list returns empty signals list.""" def test_empty_positions(self) -> None: signals = evaluate_exits([], {"AAPL": 100.0}, _default_config()) assert signals == [] def test_empty_positions_empty_prices(self) -> None: signals = evaluate_exits([], {}, _default_config()) assert signals == [] # =========================================================================== # 8. Fallback to position.current_price when ticker not in current_prices # =========================================================================== class TestPriceFallback: """When ticker is absent from current_prices, use position.current_price.""" def test_uses_position_current_price_as_fallback(self) -> None: """Ticker not in current_prices → falls back to position.current_price.""" pos = _position( ticker="MSFT", current_price=85.0, # below stop_loss stop_loss=90.0, ) # "MSFT" not in current_prices → uses 85.0 signals = evaluate_exits([pos], {"AAPL": 200.0}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "stop_hit" assert signals[0].price == 85.0 def test_uses_current_prices_when_available(self) -> None: """Ticker in current_prices → uses that price, not position.current_price.""" pos = _position( ticker="AAPL", current_price=85.0, # would trigger stop stop_loss=90.0, ) # current_prices has AAPL at 105 → above stop, no exit signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config()) assert len(signals) == 0 def test_fallback_triggers_target(self) -> None: """Fallback price can trigger target exits too.""" pos = _position( ticker="TSLA", current_price=135.0, # above target_2 target_2=130.0, ) signals = evaluate_exits([pos], {}, _default_config()) assert len(signals) == 1 assert signals[0].reason == "target_2_hit" assert signals[0].price == 135.0 # =========================================================================== # 9. Multiple positions # =========================================================================== class TestMultiplePositions: """Multiple positions evaluated independently.""" def test_multiple_positions_different_exits(self) -> None: """Each position evaluated independently; different exit types.""" pos1 = _position(position_id="p1", ticker="AAPL", stop_loss=90.0) pos2 = _position(position_id="p2", ticker="MSFT", stop_loss=40.0, target_1=50.0, target_2=130.0) pos3 = _position(position_id="p3", ticker="GOOG") prices = {"AAPL": 85.0, "MSFT": 55.0, "GOOG": 105.0} signals = evaluate_exits([pos1, pos2, pos3], prices, _default_config()) assert len(signals) == 2 # AAPL stop hit, MSFT target_1 hit, GOOG no exit by_id = {s.position_id: s for s in signals} assert by_id["p1"].reason == "stop_hit" assert by_id["p2"].reason == "target_1_hit" assert "p3" not in by_id def test_all_positions_no_exit(self) -> None: """All positions in safe zone → empty signals.""" pos1 = _position(position_id="p1", stop_loss=80.0, target_1=120.0) pos2 = _position(position_id="p2", stop_loss=80.0, target_1=120.0) signals = evaluate_exits( [pos1, pos2], {"AAPL": 100.0}, _default_config(), ) assert len(signals) == 0 # =========================================================================== # 10. Custom config — trailing_stop_atr_multiplier # =========================================================================== class TestCustomExitConfig: """Custom ATR multiplier affects trailing stop computation.""" def test_higher_multiplier_wider_trailing_stop(self) -> None: """Higher multiplier → wider trailing stop → less likely to trigger.""" # Use a pre-set trailing_stop that was ratcheted up previously. # With tight config the existing trailing stop triggers; with wide # config we use a lower existing stop that doesn't trigger. pos_tight = _position( partial_exit_done=True, trailing_stop=110.0, # previously ratcheted high atr=5.0, target_2=200.0, ) pos_wide = _position( partial_exit_done=True, trailing_stop=100.0, # lower trailing stop atr=5.0, target_2=200.0, ) config = _default_config() # Price at 108: tight trailing=max(110, 108-10)=110 → hit signals_tight = evaluate_exits([pos_tight], {"AAPL": 108.0}, config) # Price at 108: wide trailing=max(100, 108-10)=100 → not hit (108 > 100) signals_wide = evaluate_exits([pos_wide], {"AAPL": 108.0}, config) assert len(signals_tight) == 1 assert signals_tight[0].reason == "trailing_stop_hit" assert len(signals_wide) == 0