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)
207 lines
7.4 KiB
Python
207 lines
7.4 KiB
Python
"""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 12–33%.
|
||
- 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),
|
||
},
|
||
)
|