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
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)
498 lines
19 KiB
Python
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
|