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)
155 lines
4.6 KiB
Python
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)
|