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
+288
View File
@@ -0,0 +1,288 @@
"""Unit tests for services.signal_engine.signals.fibonacci — Fibonacci retracement evaluator.
Requirements: 2.1, 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.fibonacci import (
DEFAULT_MIN_BARS,
RETRACEMENT_RATIOS,
FibonacciEvaluator,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bar(
close: float,
high: float | None = None,
low: float | None = None,
) -> OHLCVBar:
"""Create a minimal OHLCVBar for testing."""
return OHLCVBar(
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
open=close,
high=high if high is not None else close,
low=low if low is not None else close,
close=close,
volume=1000.0,
)
def _make_bars(
n: int,
close: float = 100.0,
high: float | None = None,
low: float | None = None,
) -> list[OHLCVBar]:
"""Create *n* identical bars."""
return [_bar(close, high=high, low=low) for _ in range(n)]
# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------
def test_returns_none_when_insufficient_bars() -> None:
"""Requirement 2.6: return None when fewer bars than lookback."""
evaluator = FibonacciEvaluator(min_bars=20)
bars = _make_bars(10, close=100.0, high=110.0, low=90.0)
assert evaluator.evaluate(bars, "D") is None
def test_returns_none_with_empty_bars() -> None:
evaluator = FibonacciEvaluator()
assert evaluator.evaluate([], "D") is None
def test_returns_none_when_flat_market() -> None:
"""When SH == SL there is no valid retracement range."""
evaluator = FibonacciEvaluator(min_bars=5)
# All bars have the same high and low
bars = _make_bars(5, close=100.0, high=100.0, low=100.0)
assert evaluator.evaluate(bars, "D") is None
# ---------------------------------------------------------------------------
# Basic signal production
# ---------------------------------------------------------------------------
def test_produces_signal_with_sufficient_data() -> None:
"""Requirement 2.7: produces a valid SignalResult."""
evaluator = FibonacciEvaluator(min_bars=5)
# Create bars with a clear swing high and swing low
bars = [
_bar(100.0, high=100.0, low=95.0),
_bar(105.0, high=110.0, low=100.0), # swing high
_bar(95.0, high=100.0, low=90.0), # swing low
_bar(98.0, high=100.0, low=95.0),
_bar(100.0, high=102.0, low=98.0), # current price = 100
]
result = evaluator.evaluate(bars, "H4")
assert result is not None
assert result.signal_type == "fibonacci"
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_signal_metadata_contains_expected_keys() -> None:
evaluator = FibonacciEvaluator(min_bars=5)
bars = [
_bar(100.0, high=100.0, low=95.0),
_bar(105.0, high=110.0, low=100.0),
_bar(95.0, high=100.0, low=90.0),
_bar(98.0, high=100.0, low=95.0),
_bar(100.0, high=102.0, low=98.0),
]
result = evaluator.evaluate(bars, "D")
assert result is not None
meta = result.metadata
assert "swing_high" in meta
assert "swing_low" in meta
assert "retracement_levels" in meta
assert "nearest_ratio" in meta
assert "nearest_level" in meta
assert "distance_to_nearest" in meta
assert "current_price" in meta
# ---------------------------------------------------------------------------
# Retracement level formula: L(r) = SH - r * (SH - SL)
# ---------------------------------------------------------------------------
def test_retracement_levels_formula() -> None:
"""Requirement 2.1: L(r) = SH - r * (SH - SL) for all standard ratios."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100 → range = 100
bars = [
_bar(150.0, high=200.0, low=100.0), # contains both SH and SL
_bar(150.0, high=180.0, low=120.0),
_bar(150.0, high=170.0, low=130.0),
_bar(150.0, high=160.0, low=140.0),
_bar(150.0, high=155.0, low=145.0), # current close = 150
]
result = evaluator.evaluate(bars, "D")
assert result is not None
levels = result.metadata["retracement_levels"]
sh = result.metadata["swing_high"]
sl = result.metadata["swing_low"]
assert sh == 200.0
assert sl == 100.0
for ratio in RETRACEMENT_RATIOS:
expected = sh - ratio * (sh - sl)
assert abs(levels[ratio] - expected) < 1e-10, (
f"Level for ratio {ratio}: expected {expected}, got {levels[ratio]}"
)
def test_retracement_ratios_constant() -> None:
"""Verify the RETRACEMENT_RATIOS constant matches the spec."""
assert RETRACEMENT_RATIOS == [0.236, 0.382, 0.5, 0.618, 0.786]
# ---------------------------------------------------------------------------
# Signal strength — proximity to nearest level
# ---------------------------------------------------------------------------
def test_strength_is_high_when_price_at_level() -> None:
"""When current price is exactly at a retracement level, strength ≈ 1.0."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100, range = 100
# 0.5 level = 200 - 0.5 * 100 = 150
# Set current close exactly at the 0.5 level
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0), # close = 150 = 0.5 level
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.strength == 1.0
def test_strength_decreases_with_distance() -> None:
"""Strength should be lower when price is far from any retracement level."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100
# Levels: 176.4, 161.8, 150.0, 138.2, 121.4
# Price at 110 → nearest is 121.4, distance = 11.4, strength = 1 - 11.4/100 = 0.886
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(130.0, high=165.0, low=105.0),
_bar(110.0, high=115.0, low=105.0), # close = 110
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.strength < 1.0
assert result.strength > 0.0
# ---------------------------------------------------------------------------
# Direction logic
# ---------------------------------------------------------------------------
def test_direction_bullish_when_above_swing_low() -> None:
"""Price above SL → BULLISH (potential bounce)."""
evaluator = FibonacciEvaluator(min_bars=5)
# SL = 100, current close = 150 (above SL)
bars = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0),
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.direction == SignalDirection.BULLISH
def test_direction_bearish_when_below_swing_low() -> None:
"""Price below SL → BEARISH."""
evaluator = FibonacciEvaluator(min_bars=3)
# SH = 200 (bar 0 high), SL = 100 (bar 1 low)
# Current close = 95 (below SL of 100)
# The last bar's low must not be lower than SL, otherwise SL shifts down
bars = [
_bar(150.0, high=200.0, low=110.0),
_bar(120.0, high=150.0, low=100.0),
_bar(95.0, high=100.0, low=100.0), # close = 95 < SL=100, but low stays at 100
]
result = evaluator.evaluate(bars, "D")
assert result is not None
assert result.direction == SignalDirection.BEARISH
# ---------------------------------------------------------------------------
# Confidence — key ratios boost confidence
# ---------------------------------------------------------------------------
def test_confidence_higher_at_key_ratio() -> None:
"""Confidence should be boosted when nearest level is 0.5 or 0.618."""
evaluator = FibonacciEvaluator(min_bars=5)
# SH = 200, SL = 100
# 0.5 level = 150, 0.618 level = 138.2
# Price at 150 → nearest is 0.5 (key ratio)
bars_at_key = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(140.0, high=170.0, low=110.0),
_bar(155.0, high=165.0, low=130.0),
_bar(150.0, high=155.0, low=145.0), # at 0.5 level
]
result_key = evaluator.evaluate(bars_at_key, "D")
# Price at 176.4 → nearest is 0.236 (non-key ratio)
bars_at_nonkey = [
_bar(150.0, high=200.0, low=100.0),
_bar(160.0, high=180.0, low=120.0),
_bar(170.0, high=175.0, low=165.0),
_bar(175.0, high=178.0, low=172.0),
_bar(176.4, high=178.0, low=174.0), # at 0.236 level
]
result_nonkey = evaluator.evaluate(bars_at_nonkey, "D")
assert result_key is not None
assert result_nonkey is not None
# Both at their respective levels (distance ≈ 0), but key ratio gets higher confidence
assert result_key.confidence > result_nonkey.confidence
# ---------------------------------------------------------------------------
# Configurable min_bars
# ---------------------------------------------------------------------------
def test_custom_min_bars() -> None:
"""The lookback is configurable via constructor."""
evaluator = FibonacciEvaluator(min_bars=3)
bars = [
_bar(100.0, high=120.0, low=80.0),
_bar(110.0, high=115.0, low=90.0),
_bar(105.0, high=110.0, low=95.0),
]
result = evaluator.evaluate(bars, "M30")
assert result is not None
def test_default_min_bars_value() -> None:
assert DEFAULT_MIN_BARS == 20