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
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:
@@ -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
|
||||
Reference in New Issue
Block a user