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

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:
Celes Renata
2026-05-02 07:32:26 +00:00
parent 7e2343ec2c
commit f468e30af0
61 changed files with 14107 additions and 184 deletions
+592
View File
@@ -0,0 +1,592 @@
"""Integration tests for services.signal_engine.worker — Top-level orchestrator.
Tests the full evaluation tick flow with mocked DB/Redis, pipeline failure
isolation, hard filter short-circuit, and shadow mode behavior.
Requirements: 11.1, 11.2, 11.3, 11.6, 13.1, 13.6, 13.7, 15.1, 15.4, 16.1, 16.6
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.models import (
DeltaResult,
HeuristicResult,
NormalizedInput,
ProbabilisticResult,
Verdict,
)
from services.signal_engine.worker import evaluate_tick
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_config(**overrides: object) -> SignalEngineConfig:
"""Build a SignalEngineConfig with sensible test defaults."""
defaults = {
"dual_pipeline_enabled": True,
"shadow_mode": False,
}
defaults.update(overrides)
return SignalEngineConfig(**defaults)
def _normalized_input(
*,
ticker: str = "AAPL",
macro_bias: float = 0.5,
valuation_score: float = 0.8,
earnings_proximity_days: int = 30,
current_price: float = 150.0,
) -> NormalizedInput:
"""Build a NormalizedInput with test defaults that pass hard filters."""
return NormalizedInput(
ticker=ticker,
evaluated_at=datetime.now(tz=timezone.utc),
bars={},
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
macro_bias=macro_bias,
open_positions=[],
closing_prices=[100.0 + i for i in range(120)],
returns=[0.01] * 119,
current_price=current_price,
)
def _heuristic_buy() -> HeuristicResult:
return HeuristicResult(
verdict=Verdict.BUY,
confidence=0.85,
s_total=1.5,
s_company=1.0,
s_macro=0.3,
s_competitive=0.2,
signal_weights=[],
reasoning=["BUY: all conditions met"],
)
def _heuristic_skip() -> HeuristicResult:
return HeuristicResult(
verdict=Verdict.SKIP,
confidence=0.3,
s_total=0.5,
s_company=0.3,
s_macro=0.1,
s_competitive=0.1,
signal_weights=[],
reasoning=["SKIP: low confidence"],
)
def _probabilistic_buy() -> ProbabilisticResult:
return ProbabilisticResult(
verdict=Verdict.BUY,
p_up=0.72,
entropy=0.6,
ev_r=2.0,
prior=0.58,
posterior=0.72,
likelihood_ratios=[],
regime="trend_following",
reasoning=["BUY: all conditions met"],
)
def _probabilistic_skip() -> ProbabilisticResult:
return ProbabilisticResult(
verdict=Verdict.SKIP,
p_up=0.4,
entropy=0.95,
ev_r=0.5,
prior=0.5,
posterior=0.4,
likelihood_ratios=[],
regime="uncertainty",
reasoning=["SKIP: low P_up"],
)
def _delta_result(agreement: bool = True) -> DeltaResult:
return DeltaResult(
agreement=agreement,
confidence_delta=0.1,
heuristic_verdict="BUY",
probabilistic_verdict="BUY",
disagreement_reasons=[],
rolling_agreement_rate=0.9,
)
# ===========================================================================
# 1. Full tick evaluation with mocked data (Req 11.1, 11.2, 11.5, 11.6)
# ===========================================================================
class TestFullTickEvaluation:
"""Test the full evaluation tick with both pipelines producing BUY."""
@pytest.mark.asyncio
async def test_full_tick_both_buy_publishes_to_queue(self) -> None:
"""Both pipelines BUY → output persisted and published to trading queue."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_buy()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.ticker == "AAPL"
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "BUY"
# Persistence was called
mock_persist.assert_awaited_once()
# Trading queue was published to (at least one BUY, not shadow mode)
redis_client.rpush.assert_awaited_once()
call_args = redis_client.rpush.call_args
assert call_args[0][0] == "stonks:queue:trading_decisions"
@pytest.mark.asyncio
async def test_full_tick_no_buy_does_not_publish(self) -> None:
"""Both pipelines SKIP → output persisted but NOT published to queue."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_skip()
probabilistic = _probabilistic_skip()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
# Persisted
mock_persist.assert_awaited_once()
# NOT published to trading queue
redis_client.rpush.assert_not_awaited()
# ===========================================================================
# 2. Pipeline failure isolation (Req 11.3)
# ===========================================================================
class TestPipelineFailureIsolation:
"""One pipeline fails → SKIP verdict for that pipeline, other completes."""
@pytest.mark.asyncio
async def test_heuristic_fails_probabilistic_completes(self) -> None:
"""Heuristic raises exception → SKIP, probabilistic completes normally."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=False)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
side_effect=RuntimeError("heuristic boom"),
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
# Heuristic fell back to SKIP
assert output.heuristic_verdict == "SKIP"
# Probabilistic completed normally
assert output.probabilistic_verdict == "BUY"
# Still persisted
mock_persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_probabilistic_fails_heuristic_completes(self) -> None:
"""Probabilistic raises exception → SKIP, heuristic completes normally."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
heuristic = _heuristic_buy()
delta = _delta_result(agreement=False)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
side_effect=RuntimeError("probabilistic boom"),
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "SKIP"
mock_persist.assert_awaited_once()
@pytest.mark.asyncio
async def test_both_pipelines_fail_returns_none(self) -> None:
"""Both pipelines raise exceptions → returns None."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input()
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
side_effect=RuntimeError("heuristic boom"),
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
side_effect=RuntimeError("probabilistic boom"),
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is None
# Nothing persisted when both fail
mock_persist.assert_not_awaited()
# ===========================================================================
# 3. Hard filter short-circuit (Req 4.1, 4.2, 4.3)
# ===========================================================================
class TestHardFilterShortCircuit:
"""Hard filter triggers → returns None without running pipelines."""
@pytest.mark.asyncio
async def test_hard_filter_returns_none(self) -> None:
"""When hard filter triggers, evaluate_tick returns None."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config()
normalized = _normalized_input(macro_bias=-1.0)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
) as mock_heuristic,
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
) as mock_probabilistic,
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(
filtered=True, reasons=["macro_bias_negative"]
)
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is None
# Pipelines were NOT called
mock_heuristic.assert_not_called()
mock_probabilistic.assert_not_called()
# Nothing persisted
mock_persist.assert_not_awaited()
# ===========================================================================
# 4. Shadow mode behavior (Req 16.6)
# ===========================================================================
class TestShadowMode:
"""Shadow mode: persists output but does NOT publish to trading queue."""
@pytest.mark.asyncio
async def test_shadow_mode_persists_but_does_not_publish(self) -> None:
"""In shadow mode, BUY signals are persisted but not forwarded."""
pool = AsyncMock()
redis_client = AsyncMock()
config = _default_config(shadow_mode=True)
normalized = _normalized_input()
heuristic = _heuristic_buy()
probabilistic = _probabilistic_buy()
delta = _delta_result(agreement=True)
with (
patch(
"services.signal_engine.worker.normalize_input",
new_callable=AsyncMock,
return_value=normalized,
),
patch(
"services.signal_engine.worker.evaluate_exits",
return_value=[],
),
patch(
"services.signal_engine.worker.evaluate_hard_filters",
) as mock_hf,
patch(
"services.signal_engine.worker._evaluate_signals",
return_value={},
),
patch(
"services.signal_engine.worker.compute_confluence",
return_value=[],
),
patch(
"services.signal_engine.worker.classify_regime",
),
patch(
"services.signal_engine.worker.run_heuristic_pipeline",
return_value=heuristic,
),
patch(
"services.signal_engine.worker.run_probabilistic_pipeline",
return_value=probabilistic,
),
patch(
"services.signal_engine.worker.analyze_delta",
new_callable=AsyncMock,
return_value=delta,
),
patch(
"services.signal_engine.worker.persist_signal_output",
new_callable=AsyncMock,
) as mock_persist,
):
mock_hf.return_value = MagicMock(filtered=False, reasons=[])
output = await evaluate_tick(pool, redis_client, "AAPL", config)
assert output is not None
assert output.heuristic_verdict == "BUY"
assert output.probabilistic_verdict == "BUY"
# Persisted (shadow mode still persists)
mock_persist.assert_awaited_once()
# NOT published to trading queue (shadow mode blocks publishing)
redis_client.rpush.assert_not_awaited()