"""Top-level orchestrator for a single evaluation tick. Coordinates input normalization, exit evaluation, hard filters, signal evaluation, both pipelines (concurrent), delta analysis, output formatting, persistence, and Redis queue publication. Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6 """ from __future__ import annotations import asyncio import logging import time import asyncpg import redis.asyncio from services.aggregation.regime import classify_regime from services.signal_engine.config import SignalEngineConfig from services.signal_engine.confluence import compute_confluence from services.signal_engine.delta import analyze_delta from services.signal_engine.exit_engine import evaluate_exits from services.signal_engine.formatter import ( format_output, signal_output_to_recommendation, ) from services.signal_engine.hard_filter import evaluate_hard_filters from services.signal_engine.heuristic import run_heuristic_pipeline from services.signal_engine.models import ( HeuristicResult, NormalizedInput, ProbabilisticResult, SignalOutput, SignalResult, Verdict, ) from services.signal_engine.normalizer import normalize_input from services.signal_engine.persistence import persist_signal_output from services.signal_engine.probabilistic import run_probabilistic_pipeline from services.signal_engine.signals.cup_handle import CupHandleEvaluator from services.signal_engine.signals.elliott_wave import ElliottWaveEvaluator from services.signal_engine.signals.fibonacci import FibonacciEvaluator from services.signal_engine.signals.ma_stack import MAStackEvaluator from services.signal_engine.signals.rsi import RSIEvaluator logger = logging.getLogger(__name__) # Redis queue for trading decisions _TRADING_QUEUE = "stonks:queue:trading_decisions" # All signal evaluators _EVALUATORS = [ FibonacciEvaluator(), MAStackEvaluator(), RSIEvaluator(), CupHandleEvaluator(), ElliottWaveEvaluator(), ] # Default SKIP results used when a pipeline fails _SKIP_HEURISTIC = HeuristicResult( verdict=Verdict.SKIP, confidence=0.0, s_total=0.0, s_company=0.0, s_macro=0.0, s_competitive=0.0, signal_weights=[], reasoning=["pipeline_error: heuristic pipeline raised an exception"], ) _SKIP_PROBABILISTIC = ProbabilisticResult( verdict=Verdict.SKIP, p_up=0.5, entropy=1.0, ev_r=0.0, prior=0.5, posterior=0.5, likelihood_ratios=[], regime="uncertainty", reasoning=["pipeline_error: probabilistic pipeline raised an exception"], ) def _evaluate_signals( normalized: NormalizedInput, ) -> dict[str, dict[str, SignalResult]]: """Run all signal evaluators across all timeframes. Returns ``{signal_type: {timeframe: SignalResult}}`` for signals that fired. Signals that returned ``None`` (insufficient data or no trigger) are omitted. """ from services.signal_engine.normalizer import TIMEFRAMES results: dict[str, dict[str, SignalResult]] = {} for evaluator in _EVALUATORS: for tf in TIMEFRAMES: bars = normalized.bars.get(tf, []) if not bars: continue try: result = evaluator.evaluate(bars, tf) except Exception: logger.warning( "Signal evaluator %s failed on %s/%s", type(evaluator).__name__, normalized.ticker, tf, exc_info=True, ) continue if result is not None: results.setdefault(result.signal_type, {})[tf] = result return results async def evaluate_tick( pool: asyncpg.Pool, redis_client: redis.asyncio.Redis, ticker: str, config: SignalEngineConfig, ) -> SignalOutput | None: """Run a full evaluation tick for a single ticker. Steps: 1. Normalize inputs (single fetch, shared reference) 2. Evaluate exit conditions for open positions 3. Run hard filters (short-circuit if filtered) 4. Evaluate signals across timeframes via Signal Library 5. Compute confluence 6. Classify regime via existing ``classify_regime()`` 7. Run both pipelines concurrently via ``asyncio.gather`` 8. Compute delta analysis 9. Format output 10. Persist to database and publish to Redis queue Returns ``None`` if the ticker is hard-filtered or both pipelines fail. Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6 """ tick_start = time.monotonic() # Step 1: Normalize inputs normalized = await normalize_input(pool, ticker, config) # Step 2: Evaluate exit conditions (before pipelines — Req 8.6) current_price = normalized.current_price or 0.0 exit_signals = evaluate_exits( normalized.open_positions, {ticker: current_price}, config.exit_config, ) # Step 3: Hard filters filter_result = evaluate_hard_filters(normalized, config.hard_filter_config) if filter_result.filtered: logger.info( "Ticker %s hard-filtered: %s", ticker, ", ".join(filter_result.reasons), ) return None # Step 4: Evaluate signals across timeframes signal_results = _evaluate_signals(normalized) # Step 5: Compute confluence confluence_signals = compute_confluence(signal_results, config.timeframe_weights) # Step 6: Classify regime regime = classify_regime(normalized.closing_prices, normalized.returns) # Step 7: Run both pipelines concurrently heuristic_start = time.monotonic() async def _run_heuristic() -> HeuristicResult: return run_heuristic_pipeline( normalized, confluence_signals, config.heuristic_config ) async def _run_probabilistic() -> ProbabilisticResult: return run_probabilistic_pipeline( normalized, confluence_signals, regime, config.probabilistic_config ) results = await asyncio.gather( _run_heuristic(), _run_probabilistic(), return_exceptions=True, ) pipeline_elapsed = time.monotonic() - heuristic_start # Handle pipeline exceptions — SKIP verdict for failed pipeline heuristic_result: HeuristicResult probabilistic_result: ProbabilisticResult if isinstance(results[0], BaseException): logger.error( "Heuristic pipeline failed for %s: %s", ticker, results[0], exc_info=results[0], ) heuristic_result = _SKIP_HEURISTIC else: heuristic_result = results[0] if isinstance(results[1], BaseException): logger.error( "Probabilistic pipeline failed for %s: %s", ticker, results[1], exc_info=results[1], ) probabilistic_result = _SKIP_PROBABILISTIC else: probabilistic_result = results[1] # If both pipelines failed, return None if isinstance(results[0], BaseException) and isinstance(results[1], BaseException): logger.error( "Both pipelines failed for %s — skipping tick", ticker, ) return None logger.info( "Pipelines completed for %s in %.3fs — heuristic=%s, probabilistic=%s", ticker, pipeline_elapsed, heuristic_result.verdict.value, probabilistic_result.verdict.value, ) # Step 8: Delta analysis delta = await analyze_delta( heuristic_result, probabilistic_result, redis_client, ticker ) # Step 9: Format output price = normalized.current_price or 0.0 output = format_output( ticker, price, heuristic_result, probabilistic_result, delta, exit_signals, config, ) # Step 10: Persist to database await persist_signal_output(pool, output) # Step 11: Publish to trading queue (only if at least one BUY and not shadow_mode) has_buy = ( heuristic_result.verdict == Verdict.BUY or probabilistic_result.verdict == Verdict.BUY ) if has_buy and not config.shadow_mode: try: recommendation = signal_output_to_recommendation(output) await redis_client.rpush( _TRADING_QUEUE, recommendation.model_dump_json(), ) logger.info( "Published trading recommendation for %s to %s", ticker, _TRADING_QUEUE, ) except Exception: logger.error( "Failed to publish trading recommendation for %s", ticker, exc_info=True, ) elif has_buy and config.shadow_mode: logger.info( "Shadow mode: BUY signal for %s persisted but not published to trading queue", ticker, ) # Log wall-clock execution time tick_elapsed = time.monotonic() - tick_start logger.info( "Evaluation tick for %s completed in %.3fs", ticker, tick_elapsed, ) return output