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
+237
View File
@@ -0,0 +1,237 @@
"""Unit tests for services.signal_engine.signals.ma_stack — Moving average stack evaluator.
Requirements: 2.2, 2.6, 2.7
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.ma_stack import (
MA_PERIODS,
MIN_BARS,
MAStackEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(close: float) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=close,
low=close,
close=close,
volume=1000.0,
)
def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]:
"""Create *n* identical bars with the same close price."""
return [_bar(close) for _ in range(n)]
def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]:
"""Create *n* bars with linearly increasing/decreasing close prices.
``start`` is the first bar's close; each subsequent bar adds ``step``.
"""
return [_bar(start + i * step) for i in range(n)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
def test_ma_periods_constant() -> None:
assert MA_PERIODS == [10, 20, 50, 200]
def test_min_bars_constant() -> None:
assert MIN_BARS == 200
# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer bars than 200."""
evaluator = MAStackEvaluator()
bars = _make_bars(199)
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = MAStackEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_with_one_bar() -> None:
evaluator = MAStackEvaluator()
assert evaluator.evaluate([_bar(100.0)], "D") is None
# ---------------------------------------------------------------------------
# No alignment → None
# ---------------------------------------------------------------------------
def test_returns_none_when_all_mas_equal() -> None:
"""When all bars have the same close, all MAs are equal — no alignment."""
evaluator = MAStackEvaluator()
bars = _make_bars(200, close=100.0)
assert evaluator.evaluate(bars, "D") is None
# ---------------------------------------------------------------------------
# Full bullish alignment
# ---------------------------------------------------------------------------
def test_full_bullish_alignment() -> None:
"""Requirement 2.2: MA_10 > MA_20 > MA_50 > MA_200 → bullish, strength 1.0."""
evaluator = MAStackEvaluator()
# Strongly uptrending: recent prices much higher than old prices
# This ensures MA_10 > MA_20 > MA_50 > MA_200
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.direction == SignalDirection.BULLISH
assert result.strength == 1.0
assert result.confidence == 1.0 * 0.9
assert result.metadata["alignment"] == "full_bullish"
# ---------------------------------------------------------------------------
# Full bearish alignment
# ---------------------------------------------------------------------------
def test_full_bearish_alignment() -> None:
"""Requirement 2.2: MA_10 < MA_20 < MA_50 < MA_200 → bearish, strength 1.0."""
evaluator = MAStackEvaluator()
# Strongly downtrending: recent prices much lower than old prices
bars = _trending_bars(200, start=250.0, step=-1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.direction == SignalDirection.BEARISH
assert result.strength == 1.0
assert result.confidence == 1.0 * 0.9
assert result.metadata["alignment"] == "full_bearish"
# ---------------------------------------------------------------------------
# Partial bullish alignment (3/4)
# ---------------------------------------------------------------------------
def test_partial_bullish_alignment() -> None:
"""3 out of 4 MAs in bullish order → strength 0.6."""
evaluator = MAStackEvaluator()
# Build bars where MA_10 > MA_20 > MA_50 but MA_50 < MA_200
# Use flat early prices (high MA_200) then a moderate uptrend at the end
bars = _make_bars(150, close=200.0) + _trending_bars(50, start=100.0, step=2.0)
result = evaluator.evaluate(bars, "D")
# The recent uptrend should give MA_10 > MA_20 > MA_50
# but MA_200 includes the high early prices, so MA_50 < MA_200
if result is not None:
assert result.direction == SignalDirection.BULLISH
assert result.strength == 0.6
assert result.confidence == 0.6 * 0.9
assert result.metadata["alignment"] == "partial_bullish"
# ---------------------------------------------------------------------------
# Partial bearish alignment (3/4)
# ---------------------------------------------------------------------------
def test_partial_bearish_alignment() -> None:
"""3 out of 4 MAs in bearish order → strength 0.6."""
evaluator = MAStackEvaluator()
# Build bars where MA_10 < MA_20 < MA_50 but MA_50 > MA_200
# Use flat low early prices (low MA_200) then a moderate downtrend at the end
bars = _make_bars(150, close=50.0) + _trending_bars(50, start=200.0, step=-2.0)
result = evaluator.evaluate(bars, "D")
if result is not None:
assert result.direction == SignalDirection.BEARISH
assert result.strength == 0.6
assert result.confidence == 0.6 * 0.9
assert result.metadata["alignment"] == "partial_bearish"
# ---------------------------------------------------------------------------
# Signal result structure (Requirement 2.7)
# ---------------------------------------------------------------------------
def test_signal_result_structure() -> None:
"""Requirement 2.7: SignalResult has all required fields."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "H4")
assert result is not None
assert result.signal_type == "ma_stack"
assert result.timeframe == "H4"
assert 0.0 <= result.strength <= 1.0
assert 0.0 <= result.confidence <= 1.0
assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)
def test_metadata_contains_all_ma_values() -> None:
"""Metadata should include all four MA values and alignment type."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "ma_10" in meta
assert "ma_20" in meta
assert "ma_50" in meta
assert "ma_200" in meta
assert "alignment" in meta
# ---------------------------------------------------------------------------
# Timeframe passthrough
# ---------------------------------------------------------------------------
def test_timeframe_passthrough() -> None:
"""The timeframe label is passed through to the result."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
for tf in ("M30", "H1", "H4", "D", "W", "M"):
result = evaluator.evaluate(bars, tf)
assert result is not None
assert result.timeframe == tf
# ---------------------------------------------------------------------------
# Exactly 200 bars (boundary)
# ---------------------------------------------------------------------------
def test_exactly_200_bars_works() -> None:
"""Exactly 200 bars should be sufficient (boundary condition)."""
evaluator = MAStackEvaluator()
bars = _trending_bars(200, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is not None
def test_199_bars_returns_none() -> None:
"""199 bars is insufficient."""
evaluator = MAStackEvaluator()
bars = _trending_bars(199, start=50.0, step=1.0)
result = evaluator.evaluate(bars, "D")
assert result is None