feat: implement dual-pipeline signal engine service
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
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)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user