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)
593 lines
20 KiB
Python
593 lines
20 KiB
Python
"""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()
|