Files
Celes Renata 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
feat: implement dual-pipeline signal engine service
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)
2026-05-02 07:32:26 +00:00

207 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Cup & Handle pattern signal evaluator.
Detects the Cup & Handle chart pattern — a bullish continuation pattern
consisting of a U-shaped price recovery (the cup) followed by a small
consolidation pullback (the handle).
Pattern detection algorithm:
1. Find the left rim (local high in the first third of bars).
2. Find the cup bottom (lowest low between left rim and right rim area).
3. Find the right rim (local high in the last third of bars, near left rim price).
4. Identify the handle as a small pullback after the right rim (last few bars).
Pattern completeness scoring:
- Cup depth: ``(left_rim - bottom) / left_rim`` — valid range 1233%.
- Symmetry: how close left_rim and right_rim prices are (within 5% = perfect).
- Handle: small pullback (< 50% of cup depth) after right rim.
The signal is always BULLISH (cup & handle is a bullish continuation pattern).
"""
from __future__ import annotations
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
from services.signal_engine.signals.base import validate_lookback
# Default minimum number of bars required for cup & handle detection
DEFAULT_MIN_BARS: int = 30
# Cup depth valid range (as fraction of left rim price)
_CUP_DEPTH_MIN: float = 0.12 # 12%
_CUP_DEPTH_MAX: float = 0.33 # 33%
# Symmetry: maximum allowed difference between left and right rim prices
# as a fraction of left rim price for "perfect" symmetry
_SYMMETRY_PERFECT_PCT: float = 0.05 # 5%
# Handle: maximum pullback as fraction of cup depth
_HANDLE_MAX_RETRACE: float = 0.50 # 50% of cup depth
# Handle lookback: number of bars at the end to check for handle
_HANDLE_LOOKBACK_FRACTION: float = 0.15 # last 15% of bars
# Confidence multiplier
_CONFIDENCE_MULTIPLIER: float = 0.90
class CupHandleEvaluator:
"""Cup & Handle pattern signal evaluator.
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
protocol.
Parameters
----------
min_bars:
Minimum number of OHLCV bars required before the evaluator will
produce a signal. Defaults to ``30``.
"""
def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None:
self.min_bars = min_bars
# ------------------------------------------------------------------
# Public API (SignalEvaluator protocol)
# ------------------------------------------------------------------
def evaluate(
self,
bars: list[OHLCVBar],
timeframe: str,
) -> SignalResult | None:
"""Evaluate Cup & Handle pattern on *bars* for *timeframe*.
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
or when no valid cup & handle pattern is detected.
"""
if not validate_lookback(bars, self.min_bars):
return None
n = len(bars)
# --- Step 1: Find the left rim (highest high in first third) ---
first_third_end = n // 3
if first_third_end < 1:
return None
left_rim_idx = 0
left_rim_price = bars[0].high
for i in range(1, first_third_end):
if bars[i].high > left_rim_price:
left_rim_idx = i
left_rim_price = bars[i].high
if left_rim_price <= 0:
return None
# --- Step 2: Find the right rim (highest high in last third) ---
last_third_start = n - (n // 3)
if last_third_start >= n:
return None
right_rim_idx = last_third_start
right_rim_price = bars[last_third_start].high
for i in range(last_third_start + 1, n):
if bars[i].high > right_rim_price:
right_rim_idx = i
right_rim_price = bars[i].high
# --- Step 3: Find the cup bottom (lowest low between rims) ---
search_start = left_rim_idx + 1
search_end = right_rim_idx
if search_start >= search_end:
return None
bottom_idx = search_start
bottom_price = bars[search_start].low
for i in range(search_start + 1, search_end):
if bars[i].low < bottom_price:
bottom_idx = i
bottom_price = bars[i].low
# --- Step 4: Validate cup depth ---
cup_depth = left_rim_price - bottom_price
if cup_depth <= 0:
return None
cup_depth_pct = cup_depth / left_rim_price
if cup_depth_pct < _CUP_DEPTH_MIN or cup_depth_pct > _CUP_DEPTH_MAX:
return None
# --- Step 5: Score symmetry (left rim vs right rim) ---
rim_diff_pct = abs(left_rim_price - right_rim_price) / left_rim_price
if rim_diff_pct <= _SYMMETRY_PERFECT_PCT:
symmetry_score = 1.0
else:
# Linear decay from 1.0 at 5% to 0.0 at 20%
max_diff = 0.20
symmetry_score = max(0.0, 1.0 - (rim_diff_pct - _SYMMETRY_PERFECT_PCT) / (max_diff - _SYMMETRY_PERFECT_PCT))
# Right rim must be at least close to left rim (within 20%)
if symmetry_score <= 0.0:
return None
# --- Step 6: Detect and score the handle ---
handle_lookback = max(2, int(n * _HANDLE_LOOKBACK_FRACTION))
handle_bars = bars[-handle_lookback:]
# Handle is a small pullback from the right rim
handle_low = min(b.low for b in handle_bars)
handle_depth = right_rim_price - handle_low
if cup_depth <= 0:
return None
handle_retrace = handle_depth / cup_depth
if handle_retrace > _HANDLE_MAX_RETRACE:
# Handle is too deep — not a valid cup & handle
return None
# Handle score: 1.0 when handle is very shallow, decreasing as it deepens
if handle_retrace <= 0:
handle_score = 1.0
else:
handle_score = 1.0 - (handle_retrace / _HANDLE_MAX_RETRACE)
# --- Step 7: Cup depth quality score ---
# Ideal cup depth is around 20-25% — score peaks in the middle of valid range
ideal_depth = (_CUP_DEPTH_MIN + _CUP_DEPTH_MAX) / 2.0 # 0.225
depth_deviation = abs(cup_depth_pct - ideal_depth) / ((_CUP_DEPTH_MAX - _CUP_DEPTH_MIN) / 2.0)
depth_score = max(0.0, 1.0 - depth_deviation)
# --- Step 8: Compute overall completeness ---
completeness = (
0.35 * symmetry_score
+ 0.35 * depth_score
+ 0.30 * handle_score
)
completeness = max(0.0, min(1.0, completeness))
# --- Step 9: Build signal result ---
strength = completeness
confidence = completeness * _CONFIDENCE_MULTIPLIER
return SignalResult(
signal_type="cup_handle",
timeframe=timeframe,
strength=strength,
direction=SignalDirection.BULLISH,
confidence=confidence,
metadata={
"left_rim": left_rim_price,
"left_rim_idx": left_rim_idx,
"right_rim": right_rim_price,
"right_rim_idx": right_rim_idx,
"bottom": bottom_price,
"bottom_idx": bottom_idx,
"cup_depth_pct": round(cup_depth_pct, 4),
"handle_depth": round(handle_depth, 4),
"handle_retrace_pct": round(handle_retrace, 4),
"symmetry_score": round(symmetry_score, 4),
"depth_score": round(depth_score, 4),
"handle_score": round(handle_score, 4),
"completeness": round(completeness, 4),
},
)