Files
stonks-oracle/services/signal_engine/exit_engine.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

155 lines
4.6 KiB
Python

"""Exit engine — position-level exit management.
Evaluates stop-loss hits, take-profit targets, and trailing ATR-based stops
for open positions. Called once per evaluation tick *before* the signal
pipelines run so that exit signals take priority over new entry signals.
Priority order (first match wins per position):
1. stop_loss hit → EXIT_FULL, reason ``"stop_hit"``
2. target_2 hit → EXIT_FULL, reason ``"target_2_hit"``
3. trailing stop → EXIT_FULL, reason ``"trailing_stop_hit"``
4. target_1 hit → EXIT_HALF, reason ``"target_1_hit"``
Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
"""
from __future__ import annotations
import logging
from services.signal_engine.config import ExitConfig
from services.signal_engine.models import (
ExitSignal,
ExitType,
OpenPositionState,
)
logger = logging.getLogger(__name__)
def evaluate_exits(
positions: list[OpenPositionState],
current_prices: dict[str, float],
config: ExitConfig,
) -> list[ExitSignal]:
"""Evaluate exit conditions for all open positions.
For each position the current price is looked up in *current_prices*
(keyed by ticker). If the ticker is absent the position's own
``current_price`` field is used as a fallback.
Checks are applied in priority order — only the **first** matching
condition per position emits an ``ExitSignal``.
Parameters
----------
positions:
Snapshots of open positions to evaluate.
current_prices:
Latest prices keyed by ticker symbol.
config:
Exit engine configuration (trailing stop ATR multiplier, etc.).
Returns
-------
list[ExitSignal]
One signal per position that triggered an exit condition.
Positions with no exit condition produce no signal.
"""
signals: list[ExitSignal] = []
for pos in positions:
price = current_prices.get(pos.ticker, pos.current_price)
signal = _evaluate_single_position(pos, price, config)
if signal is not None:
signals.append(signal)
return signals
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _evaluate_single_position(
pos: OpenPositionState,
price: float,
config: ExitConfig,
) -> ExitSignal | None:
"""Check exit conditions for a single position in priority order.
Priority: stop_loss > target_2 > trailing_stop > target_1.
"""
# 1. Stop-loss hit (highest priority)
if price <= pos.stop_loss:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="stop_hit",
price=price,
)
# 2. Target 2 hit → full exit
if price >= pos.target_2:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="target_2_hit",
price=price,
)
# 3. Trailing stop (only active after partial exit)
if pos.partial_exit_done:
trailing_stop = _compute_trailing_stop(pos, price, config)
if price <= trailing_stop:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="trailing_stop_hit",
price=price,
)
# 4. Target 1 hit → partial exit (only if not already done)
if not pos.partial_exit_done and price >= pos.target_1:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_HALF,
reason="target_1_hit",
price=price,
)
return None
def _compute_trailing_stop(
pos: OpenPositionState,
price: float,
config: ExitConfig,
) -> float:
"""Compute the effective trailing stop level.
The trailing stop is ``price - ATR * multiplier``, but it only
ratchets **upward** — if the position already has a higher trailing
stop recorded, that value is kept.
When ATR is unavailable (``None``), the existing ``trailing_stop``
on the position is returned as-is. If neither is set, returns 0.0
(effectively no trailing stop).
"""
existing = pos.trailing_stop if pos.trailing_stop is not None else 0.0
if pos.atr is None:
return existing
new_level = price - pos.atr * config.trailing_stop_atr_multiplier
# Ratchet upward only
return max(existing, new_level)