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)
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""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
|