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)
426 lines
13 KiB
Python
426 lines
13 KiB
Python
"""Unit tests for services.signal_engine.signals.cup_handle — Cup & Handle evaluator.
|
|
|
|
Requirements: 2.4, 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.cup_handle import (
|
|
DEFAULT_MIN_BARS,
|
|
CupHandleEvaluator,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _bar(
|
|
close: float,
|
|
high: float | None = None,
|
|
low: float | None = None,
|
|
) -> OHLCVBar:
|
|
"""Create a minimal OHLCVBar for testing."""
|
|
h = high if high is not None else close
|
|
lo = low if low is not None else close
|
|
return OHLCVBar(
|
|
timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
|
open=close,
|
|
high=h,
|
|
low=lo,
|
|
close=close,
|
|
volume=1000.0,
|
|
)
|
|
|
|
|
|
def _make_cup_handle_bars(
|
|
n: int = 40,
|
|
left_rim: float = 100.0,
|
|
bottom: float = 80.0,
|
|
right_rim: float = 99.0,
|
|
handle_low: float = 96.0,
|
|
) -> list[OHLCVBar]:
|
|
"""Create a synthetic cup & handle pattern.
|
|
|
|
Generates bars that form:
|
|
1. Rise to left_rim in the first third
|
|
2. Descent to bottom in the middle
|
|
3. Rise to right_rim in the last third
|
|
4. Small pullback to handle_low at the end
|
|
"""
|
|
bars: list[OHLCVBar] = []
|
|
first_third = n // 3
|
|
last_third_start = n - (n // 3)
|
|
handle_start = n - max(2, int(n * 0.15))
|
|
|
|
for i in range(n):
|
|
if i < first_third:
|
|
# Rise to left rim
|
|
frac = i / max(1, first_third - 1)
|
|
price = bottom + frac * (left_rim - bottom)
|
|
h = price + 1.0
|
|
lo = price - 1.0
|
|
elif i < last_third_start:
|
|
# Cup: descend to bottom then rise
|
|
mid = (first_third + last_third_start) / 2.0
|
|
if i <= mid:
|
|
frac = (i - first_third) / max(1, mid - first_third)
|
|
price = left_rim - frac * (left_rim - bottom)
|
|
else:
|
|
frac = (i - mid) / max(1, last_third_start - mid)
|
|
price = bottom + frac * (right_rim - bottom)
|
|
h = price + 1.0
|
|
lo = price - 1.0
|
|
elif i < handle_start:
|
|
# Rise to right rim
|
|
frac = (i - last_third_start) / max(1, handle_start - last_third_start - 1)
|
|
price = right_rim - 2.0 + frac * 2.0
|
|
h = price + 1.0
|
|
lo = price - 1.0
|
|
else:
|
|
# Handle: small pullback
|
|
handle_len = n - handle_start
|
|
frac = (i - handle_start) / max(1, handle_len - 1)
|
|
price = right_rim - frac * (right_rim - handle_low)
|
|
h = price + 0.5
|
|
lo = price - 0.5
|
|
|
|
bars.append(_bar(price, high=h, low=lo))
|
|
|
|
# Ensure the left rim bar has the correct high
|
|
bars[first_third - 1] = _bar(
|
|
left_rim - 1.0,
|
|
high=left_rim,
|
|
low=left_rim - 2.0,
|
|
)
|
|
# Ensure the right rim bar has the correct high
|
|
right_rim_idx = last_third_start + (handle_start - last_third_start) // 2
|
|
if right_rim_idx < n:
|
|
bars[right_rim_idx] = _bar(
|
|
right_rim - 1.0,
|
|
high=right_rim,
|
|
low=right_rim - 2.0,
|
|
)
|
|
|
|
return bars
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_min_bars() -> None:
|
|
assert DEFAULT_MIN_BARS == 30
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Insufficient data → None (Requirement 2.6)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_returns_none_when_insufficient_bars() -> None:
|
|
"""Requirement 2.6: return None when fewer than min_bars."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = [_bar(100.0) for _ in range(29)]
|
|
assert evaluator.evaluate(bars, "D") is None
|
|
|
|
|
|
def test_returns_none_with_empty_bars() -> None:
|
|
evaluator = CupHandleEvaluator()
|
|
assert evaluator.evaluate([], "D") is None
|
|
|
|
|
|
def test_returns_none_with_one_bar() -> None:
|
|
evaluator = CupHandleEvaluator()
|
|
assert evaluator.evaluate([_bar(100.0)], "D") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# No pattern detected → None
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_returns_none_for_flat_market() -> None:
|
|
"""Flat prices have no cup formation."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = [_bar(100.0, high=100.0, low=100.0) for _ in range(40)]
|
|
assert evaluator.evaluate(bars, "D") is None
|
|
|
|
|
|
def test_returns_none_for_monotonic_uptrend() -> None:
|
|
"""A steady uptrend has no cup shape."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = [_bar(50.0 + i * 1.0, high=51.0 + i * 1.0, low=49.0 + i * 1.0) for i in range(40)]
|
|
# Cup depth would be too shallow or non-existent
|
|
result = evaluator.evaluate(bars, "D")
|
|
# Either None or invalid pattern — the uptrend doesn't form a cup
|
|
assert result is None
|
|
|
|
|
|
def test_returns_none_when_cup_too_shallow() -> None:
|
|
"""Cup depth < 12% should be rejected."""
|
|
evaluator = CupHandleEvaluator()
|
|
# Left rim at 100, bottom at 92 → depth = 8% (too shallow)
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=92.0,
|
|
right_rim=99.0,
|
|
handle_low=97.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is None
|
|
|
|
|
|
def test_returns_none_when_cup_too_deep() -> None:
|
|
"""Cup depth > 33% should be rejected."""
|
|
evaluator = CupHandleEvaluator()
|
|
# Left rim at 100, bottom at 60 → depth = 40% (too deep)
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=60.0,
|
|
right_rim=99.0,
|
|
handle_low=95.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is None
|
|
|
|
|
|
def test_returns_none_when_handle_too_deep() -> None:
|
|
"""Handle retracement > 50% of cup depth should be rejected."""
|
|
evaluator = CupHandleEvaluator()
|
|
# Cup depth = 100 - 80 = 20. Handle depth > 10 (50% of 20) → rejected
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=85.0, # handle depth = 99 - 85 = 14 > 10
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Valid pattern detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_detects_valid_cup_and_handle() -> None:
|
|
"""Requirement 2.4: detect cup formation and handle."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=95.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
assert result.signal_type == "cup_handle"
|
|
assert result.direction == SignalDirection.BULLISH
|
|
|
|
|
|
def test_always_bullish_direction() -> None:
|
|
"""Cup & Handle is always a bullish continuation pattern."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=98.0,
|
|
handle_low=95.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
assert result.direction == SignalDirection.BULLISH
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Completeness scoring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strength_in_unit_interval() -> None:
|
|
"""Strength must be in [0, 1]."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
assert 0.0 <= result.strength <= 1.0
|
|
|
|
|
|
def test_confidence_in_unit_interval() -> None:
|
|
"""Confidence must be in [0, 1]."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
assert 0.0 <= result.confidence <= 1.0
|
|
|
|
|
|
def test_confidence_proportional_to_completeness() -> None:
|
|
"""Requirement 2.4: confidence proportional to pattern completeness."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
# confidence = completeness * 0.90
|
|
expected_confidence = result.strength * 0.90
|
|
assert abs(result.confidence - expected_confidence) < 1e-9
|
|
|
|
|
|
def test_better_symmetry_yields_higher_completeness() -> None:
|
|
"""More symmetric rims should produce higher completeness."""
|
|
evaluator = CupHandleEvaluator()
|
|
|
|
# Good symmetry: right rim very close to left rim
|
|
bars_good = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=100.0,
|
|
handle_low=96.0,
|
|
)
|
|
result_good = evaluator.evaluate(bars_good, "D")
|
|
|
|
# Worse symmetry: right rim further from left rim
|
|
bars_worse = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=88.0,
|
|
handle_low=85.0,
|
|
)
|
|
result_worse = evaluator.evaluate(bars_worse, "D")
|
|
|
|
if result_good is not None and result_worse is not None:
|
|
assert result_good.metadata["symmetry_score"] >= result_worse.metadata["symmetry_score"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metadata (Requirement 2.7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_metadata_contains_required_fields() -> None:
|
|
"""Metadata should include left_rim, right_rim, bottom, handle_depth, completeness."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
meta = result.metadata
|
|
assert "left_rim" in meta
|
|
assert "right_rim" in meta
|
|
assert "bottom" in meta
|
|
assert "handle_depth" in meta
|
|
assert "completeness" in meta
|
|
assert "cup_depth_pct" in meta
|
|
assert "symmetry_score" in meta
|
|
assert "handle_score" in meta
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signal result structure (Requirement 2.7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_signal_result_structure() -> None:
|
|
"""Requirement 2.7: SignalResult has all required fields."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
assert result is not None
|
|
assert result.signal_type == "cup_handle"
|
|
assert result.timeframe == "D"
|
|
assert 0.0 <= result.strength <= 1.0
|
|
assert 0.0 <= result.confidence <= 1.0
|
|
assert result.direction == SignalDirection.BULLISH
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Timeframe passthrough
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_timeframe_passthrough() -> None:
|
|
"""The timeframe label is passed through to the result."""
|
|
evaluator = CupHandleEvaluator()
|
|
bars = _make_cup_handle_bars(
|
|
n=40,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
for tf in ("M30", "H1", "H4", "D", "W", "M"):
|
|
result = evaluator.evaluate(bars, tf)
|
|
assert result is not None
|
|
assert result.timeframe == tf
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Custom min_bars
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_custom_min_bars() -> None:
|
|
"""CupHandleEvaluator with a custom min_bars should use that value."""
|
|
evaluator = CupHandleEvaluator(min_bars=50)
|
|
assert evaluator.min_bars == 50
|
|
# 40 bars should be insufficient
|
|
bars = _make_cup_handle_bars(n=40)
|
|
assert evaluator.evaluate(bars, "D") is None
|
|
|
|
|
|
def test_exactly_min_bars_works() -> None:
|
|
"""Exactly min_bars should be sufficient if pattern is present."""
|
|
evaluator = CupHandleEvaluator(min_bars=30)
|
|
bars = _make_cup_handle_bars(
|
|
n=30,
|
|
left_rim=100.0,
|
|
bottom=80.0,
|
|
right_rim=99.0,
|
|
handle_low=96.0,
|
|
)
|
|
result = evaluator.evaluate(bars, "D")
|
|
# Should produce a result if the pattern is valid
|
|
# (may be None if the synthetic data doesn't form a clean pattern at 30 bars)
|
|
# At minimum, it should not crash
|
|
assert result is None or result.signal_type == "cup_handle"
|