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