Files
stonks-oracle/tests/test_signal_engine_exit.py
T
Celes Renata f468e30af0
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
feat: implement dual-pipeline signal engine service
New service at services/signal_engine/ implementing concurrent heuristic
(deterministic scoring) and probabilistic (Bayesian inference) pipelines
that evaluate technical signals across 6 timeframes (M30-M) and produce
independent BUY/WATCH/SKIP verdicts per ticker per evaluation tick.

Components:
- Input Normalizer: multi-source data assembly with sentinel fallbacks
- Signal Library: Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave
- Multi-Timeframe Confluence Engine: weighted scoring with D/W/M anchors
- Hard Filter Engine: macro_bias, valuation, earnings proximity gating
- Heuristic Pipeline: S_total scoring with confidence-gated verdicts
- Probabilistic Pipeline: Bayesian log-odds with regime priors, entropy
  gating, EV_R calculation, and signal correlation penalty
- Exit Engine: stop-loss, targets, trailing ATR-based stops
- Delta Analyzer: pipeline agreement tracking with rolling Redis metrics
- Output Formatter: SignalOutput contract + Recommendation schema mapping
- Worker orchestrator: concurrent pipelines with failure isolation
- Main entry point: queue polling with fail-safe config loading

Infrastructure:
- Migration 039: signal_engine_outputs table with 3 indexes
- Helm chart: signalEngine service entry (processing tier)
- Redis key: QUEUE_SIGNAL_ENGINE constant

Tests: 390 tests (unit + property-based) covering all components
Config: dual_pipeline_enabled=false by default (safe rollout)
2026-05-02 07:32:26 +00:00

498 lines
19 KiB
Python

"""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